workday-daemon 0.17.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/cli.js +196 -39
- package/dist/cli.js.map +1 -1
- package/dist/core/constants.d.ts +7 -1
- package/dist/core/constants.js +11 -1
- package/dist/core/constants.js.map +1 -1
- package/dist/core/day-edit.d.ts +41 -0
- package/dist/core/day-edit.js +75 -0
- package/dist/core/day-edit.js.map +1 -0
- package/dist/core/session-tracker.d.ts +6 -0
- package/dist/core/session-tracker.js +9 -0
- package/dist/core/session-tracker.js.map +1 -1
- package/dist/core/types.d.ts +97 -1
- package/dist/core/types.js +11 -0
- package/dist/core/types.js.map +1 -1
- package/dist/http-server.d.ts +20 -0
- package/dist/http-server.js +266 -45
- package/dist/http-server.js.map +1 -1
- package/dist/push/month-report.d.ts +18 -0
- package/dist/push/month-report.js +132 -0
- package/dist/push/month-report.js.map +1 -0
- package/dist/push/push-log.d.ts +15 -0
- package/dist/push/push-log.js +70 -0
- package/dist/push/push-log.js.map +1 -0
- package/dist/push/reconcile.d.ts +21 -0
- package/dist/push/reconcile.js +292 -0
- package/dist/push/reconcile.js.map +1 -0
- package/dist/push/tempo-approvals.d.ts +12 -0
- package/dist/push/tempo-approvals.js +104 -0
- package/dist/push/tempo-approvals.js.map +1 -0
- package/dist/push/tempo-client.d.ts +63 -0
- package/dist/push/tempo-client.js +46 -10
- package/dist/push/tempo-client.js.map +1 -1
- package/dist/push/tempo-pusher.d.ts +4 -4
- package/dist/push/tempo-pusher.js +88 -162
- package/dist/push/tempo-pusher.js.map +1 -1
- package/dist/push/tempo-schedule.d.ts +10 -0
- package/dist/push/tempo-schedule.js +81 -0
- package/dist/push/tempo-schedule.js.map +1 -0
- package/dist/push/tempo-snapshot.d.ts +18 -0
- package/dist/push/tempo-snapshot.js +98 -0
- package/dist/push/tempo-snapshot.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getDataDir } from '../core/config.js';
|
|
4
|
+
import { APPROVAL_CACHE_FILE, APPROVAL_CACHE_TTL_MS } from '../core/constants.js';
|
|
5
|
+
import { TempoClient, TempoApiError } from './tempo-client.js';
|
|
6
|
+
import { getAccountId, isJiraConfigured } from './jira-client.js';
|
|
7
|
+
import { getMonthRange } from './month-report.js';
|
|
8
|
+
function getCachePath() {
|
|
9
|
+
return join(getDataDir(), APPROVAL_CACHE_FILE);
|
|
10
|
+
}
|
|
11
|
+
function readCache() {
|
|
12
|
+
const path = getCachePath();
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeCache(cache) {
|
|
23
|
+
try {
|
|
24
|
+
writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
catch { /* best effort — cache is an optimization */ }
|
|
27
|
+
}
|
|
28
|
+
/** Drop the whole cache — Tempo-side timeSpentSeconds just changed. */
|
|
29
|
+
export function invalidateApprovalCache() {
|
|
30
|
+
try {
|
|
31
|
+
rmSync(getCachePath(), { force: true });
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
}
|
|
35
|
+
function monthKey(year, month) {
|
|
36
|
+
return `${year}-${String(month).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
function fromEntry(entry, fromCache) {
|
|
39
|
+
return {
|
|
40
|
+
available: true,
|
|
41
|
+
period: entry.period,
|
|
42
|
+
statusKey: entry.statusKey,
|
|
43
|
+
requiredSeconds: entry.requiredSeconds,
|
|
44
|
+
timeSpentSeconds: entry.timeSpentSeconds,
|
|
45
|
+
canSubmit: entry.canSubmit,
|
|
46
|
+
fromCache,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Empty degraded response — also used by callers with no secrets at all. */
|
|
50
|
+
export function approvalUnavailable(reason) {
|
|
51
|
+
return {
|
|
52
|
+
available: false,
|
|
53
|
+
reason,
|
|
54
|
+
period: null,
|
|
55
|
+
statusKey: null,
|
|
56
|
+
requiredSeconds: null,
|
|
57
|
+
timeSpentSeconds: null,
|
|
58
|
+
canSubmit: false,
|
|
59
|
+
fromCache: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Timesheet approval for the period containing the given month (period =
|
|
64
|
+
* calendar month on approvalPeriod=MONTH instances). Needs both a Jira
|
|
65
|
+
* account (accountId lookup) and a Tempo token with approvals:view. Cached
|
|
66
|
+
* briefly; the cache is dropped after every successful push.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveMonthApproval(year, month, secrets, forceRefresh = false) {
|
|
69
|
+
if (!secrets.Tempo_Token || secrets.Tempo_Token.trim().length === 0 || !isJiraConfigured(secrets)) {
|
|
70
|
+
return approvalUnavailable('no-token');
|
|
71
|
+
}
|
|
72
|
+
const key = monthKey(year, month);
|
|
73
|
+
const cache = readCache();
|
|
74
|
+
const cached = cache[key];
|
|
75
|
+
if (!forceRefresh && cached
|
|
76
|
+
&& Date.now() - Date.parse(cached.fetchedAt) < APPROVAL_CACHE_TTL_MS) {
|
|
77
|
+
return fromEntry(cached, true);
|
|
78
|
+
}
|
|
79
|
+
const { from } = getMonthRange(year, month);
|
|
80
|
+
try {
|
|
81
|
+
const accountId = await getAccountId(secrets);
|
|
82
|
+
const client = new TempoClient(secrets.Tempo_Token);
|
|
83
|
+
const raw = await client.getUserTimesheetApproval(accountId, from);
|
|
84
|
+
const entry = {
|
|
85
|
+
fetchedAt: new Date().toISOString(),
|
|
86
|
+
period: raw.period ?? null,
|
|
87
|
+
statusKey: raw.status?.key ?? null,
|
|
88
|
+
requiredSeconds: raw.requiredSeconds ?? null,
|
|
89
|
+
timeSpentSeconds: raw.timeSpentSeconds ?? null,
|
|
90
|
+
canSubmit: raw.actions?.submit !== undefined,
|
|
91
|
+
};
|
|
92
|
+
cache[key] = entry;
|
|
93
|
+
writeCache(cache);
|
|
94
|
+
return fromEntry(entry, false);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof TempoApiError && err.status === 403)
|
|
98
|
+
return approvalUnavailable('scope');
|
|
99
|
+
if (cached)
|
|
100
|
+
return fromEntry(cached, true); // stale beats nothing
|
|
101
|
+
return approvalUnavailable('error');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=tempo-approvals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tempo-approvals.js","sourceRoot":"","sources":["../../src/push/tempo-approvals.ts"],"names":[],"mappings":"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"}
|
|
@@ -14,6 +14,57 @@ export interface TempoWorkAttribute {
|
|
|
14
14
|
readonly values?: readonly string[];
|
|
15
15
|
readonly names?: Readonly<Record<string, string>>;
|
|
16
16
|
}
|
|
17
|
+
/** Worklog as the /4/worklogs list endpoints return it. */
|
|
18
|
+
export interface RawTempoWorklog {
|
|
19
|
+
readonly tempoWorklogId: number;
|
|
20
|
+
readonly issue?: {
|
|
21
|
+
readonly id: number;
|
|
22
|
+
};
|
|
23
|
+
readonly issueId?: number;
|
|
24
|
+
readonly startDate: string;
|
|
25
|
+
readonly timeSpentSeconds: number;
|
|
26
|
+
readonly description?: string;
|
|
27
|
+
readonly updatedAt?: string;
|
|
28
|
+
readonly attributes?: {
|
|
29
|
+
readonly values?: ReadonlyArray<{
|
|
30
|
+
readonly key: string;
|
|
31
|
+
readonly value: string;
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Map a raw API worklog to our full snapshot shape (description/activity/updatedAt kept). */
|
|
36
|
+
export declare function mapTempoWorklog(raw: RawTempoWorklog): TempoWorklog;
|
|
37
|
+
/** Tempo HTTP failure with the status preserved — 403 means "scope missing". */
|
|
38
|
+
export declare class TempoApiError extends Error {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
constructor(status: number, message: string);
|
|
41
|
+
}
|
|
42
|
+
/** Raw /4/user-schedule day (DaySchedule in the Tempo OpenAPI spec). */
|
|
43
|
+
export interface TempoScheduleDay {
|
|
44
|
+
readonly date: string;
|
|
45
|
+
readonly requiredSeconds: number;
|
|
46
|
+
readonly type: string;
|
|
47
|
+
readonly holiday?: {
|
|
48
|
+
readonly name?: string;
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
readonly durationSeconds?: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Raw /4/timesheet-approvals/user response (TimesheetApproval in the spec). */
|
|
54
|
+
export interface TempoTimesheetApproval {
|
|
55
|
+
readonly period: {
|
|
56
|
+
readonly from: string;
|
|
57
|
+
readonly to: string;
|
|
58
|
+
};
|
|
59
|
+
readonly requiredSeconds: number;
|
|
60
|
+
readonly timeSpentSeconds: number;
|
|
61
|
+
readonly status?: {
|
|
62
|
+
readonly key?: string;
|
|
63
|
+
};
|
|
64
|
+
readonly actions?: {
|
|
65
|
+
readonly submit?: unknown;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
17
68
|
export declare class TempoClient {
|
|
18
69
|
private readonly token;
|
|
19
70
|
private lastRequestTime;
|
|
@@ -22,6 +73,18 @@ export declare class TempoClient {
|
|
|
22
73
|
private request;
|
|
23
74
|
/** Fetch user worklogs for a date range with pagination */
|
|
24
75
|
getUserWorklogs(accountId: string, from: string, to: string): Promise<TempoWorklog[]>;
|
|
76
|
+
/**
|
|
77
|
+
* Day schedule of the token's user (requiredSeconds, day type, holiday).
|
|
78
|
+
* Needs scope schemes:view — 403 without it. Not paginated: the endpoint
|
|
79
|
+
* takes only from/to and returns every day of the range.
|
|
80
|
+
*/
|
|
81
|
+
getUserSchedule(from: string, to: string): Promise<TempoScheduleDay[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Current timesheet approval for the period containing `from` (period
|
|
84
|
+
* granularity comes from globalconfiguration.approvalPeriod). Needs scope
|
|
85
|
+
* approvals:view — 403 without it.
|
|
86
|
+
*/
|
|
87
|
+
getUserTimesheetApproval(accountId: string, from: string): Promise<TempoTimesheetApproval>;
|
|
25
88
|
/** Fetch all work attributes (e.g. _Activity_ STATIC_LIST values). */
|
|
26
89
|
getWorkAttributes(): Promise<TempoWorkAttribute[]>;
|
|
27
90
|
/** Build the shared worklog body. activity defaults to Development; description omitted when empty. */
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { TEMPO_BASE_URL, TEMPO_RATE_LIMIT_MS, ACTIVITY_ATTRIBUTE_KEY, DEFAULT_ACTIVITY } from '../core/constants.js';
|
|
2
|
+
/** Map a raw API worklog to our full snapshot shape (description/activity/updatedAt kept). */
|
|
3
|
+
export function mapTempoWorklog(raw) {
|
|
4
|
+
const activity = raw.attributes?.values?.find(v => v.key === ACTIVITY_ATTRIBUTE_KEY)?.value;
|
|
5
|
+
return {
|
|
6
|
+
tempoWorklogId: raw.tempoWorklogId,
|
|
7
|
+
issueId: raw.issue?.id ?? raw.issueId ?? 0,
|
|
8
|
+
startDate: raw.startDate,
|
|
9
|
+
timeSpentSeconds: raw.timeSpentSeconds,
|
|
10
|
+
...(raw.description !== undefined ? { description: raw.description } : {}),
|
|
11
|
+
...(activity !== undefined ? { activity } : {}),
|
|
12
|
+
...(raw.updatedAt !== undefined ? { updatedAt: raw.updatedAt } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Tempo HTTP failure with the status preserved — 403 means "scope missing". */
|
|
16
|
+
export class TempoApiError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
constructor(status, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
2
23
|
export class TempoClient {
|
|
3
24
|
token;
|
|
4
25
|
lastRequestTime = 0;
|
|
@@ -29,7 +50,7 @@ export class TempoClient {
|
|
|
29
50
|
});
|
|
30
51
|
if (!res.ok) {
|
|
31
52
|
const text = await res.text();
|
|
32
|
-
throw new
|
|
53
|
+
throw new TempoApiError(res.status, `Tempo API ${res.status} ${method} ${path}: ${text.slice(0, 300)}`);
|
|
33
54
|
}
|
|
34
55
|
// DELETE returns 204 No Content
|
|
35
56
|
if (res.status === 204)
|
|
@@ -45,21 +66,36 @@ export class TempoClient {
|
|
|
45
66
|
const path = `/4/worklogs/user/${encodeURIComponent(accountId)}`
|
|
46
67
|
+ `?from=${from}&to=${to}&offset=${offset}&limit=${limit}`;
|
|
47
68
|
const response = await this.request('GET', path);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
issueId: wl.issue?.id ?? wl.issueId ?? 0,
|
|
52
|
-
startDate: wl.startDate,
|
|
53
|
-
timeSpentSeconds: wl.timeSpentSeconds,
|
|
54
|
-
});
|
|
69
|
+
const items = response.results ?? [];
|
|
70
|
+
for (const wl of items) {
|
|
71
|
+
allWorklogs.push(mapTempoWorklog(wl));
|
|
55
72
|
}
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
// metadata.count is the PAGE size, not the total — the only reliable
|
|
74
|
+
// "more pages" signal is metadata.next (verified live 2026-07-07).
|
|
75
|
+
if (!response.metadata?.next || items.length === 0)
|
|
58
76
|
break;
|
|
59
77
|
offset += limit;
|
|
60
78
|
}
|
|
61
79
|
return allWorklogs;
|
|
62
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Day schedule of the token's user (requiredSeconds, day type, holiday).
|
|
83
|
+
* Needs scope schemes:view — 403 without it. Not paginated: the endpoint
|
|
84
|
+
* takes only from/to and returns every day of the range.
|
|
85
|
+
*/
|
|
86
|
+
async getUserSchedule(from, to) {
|
|
87
|
+
const response = await this.request('GET', `/4/user-schedule?from=${from}&to=${to}`);
|
|
88
|
+
return response.results ?? [];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Current timesheet approval for the period containing `from` (period
|
|
92
|
+
* granularity comes from globalconfiguration.approvalPeriod). Needs scope
|
|
93
|
+
* approvals:view — 403 without it.
|
|
94
|
+
*/
|
|
95
|
+
async getUserTimesheetApproval(accountId, from) {
|
|
96
|
+
const path = `/4/timesheet-approvals/user/${encodeURIComponent(accountId)}?from=${from}`;
|
|
97
|
+
return await this.request('GET', path);
|
|
98
|
+
}
|
|
63
99
|
/** Fetch all work attributes (e.g. _Activity_ STATIC_LIST values). */
|
|
64
100
|
async getWorkAttributes() {
|
|
65
101
|
const response = await this.request('GET', '/4/work-attributes?limit=100');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tempo-client.js","sourceRoot":"","sources":["../../src/push/tempo-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"tempo-client.js","sourceRoot":"","sources":["../../src/push/tempo-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type AppConfig, type Secrets, type
|
|
1
|
+
import { type AppConfig, type Secrets, type PushPlanEntry, type PushResult, type PushResponse } from '../core/types.js';
|
|
2
2
|
import { TempoClient } from './tempo-client.js';
|
|
3
|
-
|
|
4
|
-
export
|
|
3
|
+
import { buildPushPlan } from './reconcile.js';
|
|
4
|
+
export { buildPushPlan };
|
|
5
5
|
/** Execute mutations from the plan, update push log */
|
|
6
6
|
export declare function executePlan(plan: readonly PushPlanEntry[], tempoClient: TempoClient, accountId: string): Promise<PushResult>;
|
|
7
7
|
interface RunPushOptions {
|
|
@@ -11,7 +11,7 @@ interface RunPushOptions {
|
|
|
11
11
|
readonly config: AppConfig;
|
|
12
12
|
readonly secrets: Secrets;
|
|
13
13
|
readonly filePath?: string;
|
|
14
|
+
readonly force?: boolean;
|
|
14
15
|
}
|
|
15
16
|
/** Full push pipeline: build report → resolve Jira → fetch Tempo → plan → execute */
|
|
16
17
|
export declare function runPush(options: RunPushOptions): Promise<PushResponse>;
|
|
17
|
-
export {};
|
|
@@ -1,161 +1,21 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { getDataDir } from '../core/config.js';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
4
2
|
import { readDailyLog, writeDailyLog } from '../core/daily-log.js';
|
|
5
|
-
import { PUSH_LOG_FILE, TEMPO_TOLERANCE_SECONDS } from '../core/constants.js';
|
|
6
3
|
import { DayStatus } from '../core/types.js';
|
|
7
4
|
import { buildReport } from './report-builder.js';
|
|
8
5
|
import { getAccountId, resolveIssueIds } from './jira-client.js';
|
|
9
6
|
import { TempoClient } from './tempo-client.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
function loadPushLog() {
|
|
15
|
-
const path = getPushLogPath();
|
|
16
|
-
if (!existsSync(path))
|
|
17
|
-
return {};
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
function savePushLog(log) {
|
|
26
|
-
writeFileSync(getPushLogPath(), JSON.stringify(log, null, 2), 'utf-8');
|
|
27
|
-
}
|
|
28
|
-
function pushLogKey(date, task, entryId) {
|
|
29
|
-
return entryId ? `${date}|${task}|m:${entryId}` : `${date}|${task}`;
|
|
30
|
-
}
|
|
7
|
+
import { invalidateApprovalCache } from './tempo-approvals.js';
|
|
8
|
+
import { loadPushLog, savePushLog, pushLogKey, loadTombstones, removeTombstonesByWorklogIds } from './push-log.js';
|
|
9
|
+
import { refreshSnapshotsInRange } from './tempo-snapshot.js';
|
|
10
|
+
import { buildPushPlan, formatHours } from './reconcile.js';
|
|
31
11
|
// ─── Push plan ───────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
const plan = [];
|
|
35
|
-
// Index Tempo worklogs by (date, issueId) for fast lookup
|
|
36
|
-
const tempoByKey = new Map();
|
|
37
|
-
for (const wl of tempoWorklogs) {
|
|
38
|
-
const key = `${wl.startDate}|${wl.issueId}`;
|
|
39
|
-
const list = tempoByKey.get(key) ?? [];
|
|
40
|
-
list.push(wl);
|
|
41
|
-
tempoByKey.set(key, list);
|
|
42
|
-
}
|
|
43
|
-
// Worklogs we own as MANUAL entries — excluded from the session aggregate so a
|
|
44
|
-
// session line never treats its own task's manual worklogs as extra/foreign.
|
|
45
|
-
const manualOwnedTempoIds = new Set();
|
|
46
|
-
for (const [key, logEntry] of Object.entries(pushLog)) {
|
|
47
|
-
if (key.includes('|m:'))
|
|
48
|
-
manualOwnedTempoIds.add(logEntry.tempoWorklogId);
|
|
49
|
-
}
|
|
50
|
-
// Track which Tempo worklogs are accounted for by our report
|
|
51
|
-
const accountedTempoIds = new Set();
|
|
52
|
-
for (const entry of report) {
|
|
53
|
-
const jira = jiraMap.get(entry.task);
|
|
54
|
-
if (!jira) {
|
|
55
|
-
plan.push({
|
|
56
|
-
date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
|
|
57
|
-
action: 'error', detail: 'Unresolved in Jira',
|
|
58
|
-
kind: entry.kind, entryId: entry.entryId, description: entry.description, activity: entry.activity,
|
|
59
|
-
});
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
const tempoMatches = tempoByKey.get(`${entry.date}|${jira.issueId}`) ?? [];
|
|
63
|
-
// ── Manual entry: its own worklog, keyed by entryId (stable across edits) ──
|
|
64
|
-
if (entry.kind === 'manual') {
|
|
65
|
-
const base = {
|
|
66
|
-
date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
|
|
67
|
-
issueId: jira.issueId, kind: 'manual',
|
|
68
|
-
entryId: entry.entryId, description: entry.description, activity: entry.activity,
|
|
69
|
-
};
|
|
70
|
-
const key = pushLogKey(entry.date, entry.task, entry.entryId);
|
|
71
|
-
const logEntry = pushLog[key];
|
|
72
|
-
const live = logEntry && tempoMatches.some(w => w.tempoWorklogId === logEntry.tempoWorklogId)
|
|
73
|
-
? logEntry : null;
|
|
74
|
-
if (live) {
|
|
75
|
-
accountedTempoIds.add(live.tempoWorklogId);
|
|
76
|
-
const timeDrift = Math.abs(live.timeSpentSeconds - entry.totalSeconds) > TEMPO_TOLERANCE_SECONDS;
|
|
77
|
-
const textDrift = (live.description ?? '') !== (entry.description ?? '')
|
|
78
|
-
|| (live.activity ?? '') !== (entry.activity ?? '');
|
|
79
|
-
if (!timeDrift && !textDrift) {
|
|
80
|
-
plan.push({ ...base, action: 'skip', detail: `Already pushed (${formatHours(live.timeSpentSeconds)})`, existingWorklogId: live.tempoWorklogId });
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
const detail = timeDrift
|
|
84
|
-
? `${formatHours(live.timeSpentSeconds)} → ${formatHours(entry.totalSeconds)}`
|
|
85
|
-
: 'text/activity changed';
|
|
86
|
-
plan.push({ ...base, action: 'update', detail, existingWorklogId: live.tempoWorklogId });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
// No live worklog of ours — create. Foreign worklogs on the same issue+date
|
|
91
|
-
// are irrelevant: this entry has its own identity (entryId).
|
|
92
|
-
plan.push({ ...base, action: 'create', detail: `New (${formatHours(entry.totalSeconds)})` });
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
// ── Session aggregate: one worklog per (date, task) ──
|
|
97
|
-
const base = {
|
|
98
|
-
date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
|
|
99
|
-
issueId: jira.issueId, kind: 'session',
|
|
100
|
-
};
|
|
101
|
-
const logEntry = pushLog[pushLogKey(entry.date, entry.task)];
|
|
102
|
-
// Drop manual-owned worklogs: they belong to manual lines, not this aggregate.
|
|
103
|
-
const sessionMatches = tempoMatches.filter(w => !manualOwnedTempoIds.has(w.tempoWorklogId));
|
|
104
|
-
const validLogEntry = logEntry && sessionMatches.some(w => w.tempoWorklogId === logEntry.tempoWorklogId)
|
|
105
|
-
? logEntry : null;
|
|
106
|
-
if (validLogEntry) {
|
|
107
|
-
accountedTempoIds.add(validLogEntry.tempoWorklogId);
|
|
108
|
-
const diff = Math.abs(validLogEntry.timeSpentSeconds - entry.totalSeconds);
|
|
109
|
-
if (diff <= TEMPO_TOLERANCE_SECONDS) {
|
|
110
|
-
plan.push({ ...base, action: 'skip', detail: `Already pushed (${formatHours(validLogEntry.timeSpentSeconds)})`, existingWorklogId: validLogEntry.tempoWorklogId });
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
plan.push({ ...base, action: 'update', detail: `${formatHours(validLogEntry.timeSpentSeconds)} → ${formatHours(entry.totalSeconds)}`, existingWorklogId: validLogEntry.tempoWorklogId });
|
|
114
|
-
}
|
|
115
|
-
for (const wl of sessionMatches)
|
|
116
|
-
accountedTempoIds.add(wl.tempoWorklogId);
|
|
117
|
-
}
|
|
118
|
-
else if (sessionMatches.length > 0) {
|
|
119
|
-
for (const wl of sessionMatches)
|
|
120
|
-
accountedTempoIds.add(wl.tempoWorklogId);
|
|
121
|
-
const existingTotal = sessionMatches.reduce((s, w) => s + w.timeSpentSeconds, 0);
|
|
122
|
-
const diff = Math.abs(existingTotal - entry.totalSeconds);
|
|
123
|
-
if (diff <= TEMPO_TOLERANCE_SECONDS) {
|
|
124
|
-
plan.push({ ...base, action: 'skip', detail: `Exists in Tempo (${formatHours(existingTotal)})`, extraWorklogIds: sessionMatches.map(w => w.tempoWorklogId) });
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
plan.push({ ...base, action: 'create', detail: `Tempo has ${formatHours(existingTotal)}, adding ${formatHours(entry.totalSeconds)}`, extraWorklogIds: sessionMatches.map(w => w.tempoWorklogId) });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
plan.push({ ...base, action: 'create', detail: `New (${formatHours(entry.totalSeconds)})` });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// Tempo-only worklogs (not matched by our report) — show, never mutate.
|
|
135
|
-
for (const wl of tempoWorklogs) {
|
|
136
|
-
if (accountedTempoIds.has(wl.tempoWorklogId))
|
|
137
|
-
continue;
|
|
138
|
-
let taskKey = `issue:${wl.issueId}`;
|
|
139
|
-
for (const [key, jira] of jiraMap) {
|
|
140
|
-
if (jira.issueId === wl.issueId) {
|
|
141
|
-
taskKey = key;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
plan.push({
|
|
146
|
-
date: wl.startDate, task: taskKey, targetSeconds: wl.timeSpentSeconds,
|
|
147
|
-
action: 'skip', detail: `Tempo only (${formatHours(wl.timeSpentSeconds)})`,
|
|
148
|
-
existingWorklogId: wl.tempoWorklogId, kind: 'session',
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
// Sort by date, then task
|
|
152
|
-
plan.sort((a, b) => a.date.localeCompare(b.date) || a.task.localeCompare(b.task));
|
|
153
|
-
return plan;
|
|
154
|
-
}
|
|
12
|
+
// The diff engine lives in reconcile.ts; re-exported here for existing callers.
|
|
13
|
+
export { buildPushPlan };
|
|
155
14
|
// ─── Execute plan ────────────────────────────────────────────────────────
|
|
156
15
|
/** Execute mutations from the plan, update push log */
|
|
157
16
|
export async function executePlan(plan, tempoClient, accountId) {
|
|
158
17
|
const pushLog = loadPushLog();
|
|
18
|
+
const deletedWorklogIds = new Set();
|
|
159
19
|
let posted = 0;
|
|
160
20
|
let updated = 0;
|
|
161
21
|
let deleted = 0;
|
|
@@ -227,15 +87,49 @@ export async function executePlan(plan, tempoClient, accountId) {
|
|
|
227
87
|
}
|
|
228
88
|
break;
|
|
229
89
|
}
|
|
90
|
+
case 'delete': {
|
|
91
|
+
if (!entry.existingWorklogId) {
|
|
92
|
+
failed++;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await tempoClient.deleteWorklog(entry.existingWorklogId);
|
|
97
|
+
delete pushLog[key]; // stray ownership, if any
|
|
98
|
+
deletedWorklogIds.add(entry.existingWorklogId);
|
|
99
|
+
deleted++;
|
|
100
|
+
console.log(` DEL ${entry.date} ${entry.task} ${formatHours(entry.targetSeconds)}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
failed++;
|
|
104
|
+
console.error(` FAIL DEL ${entry.date} ${entry.task}: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
230
108
|
case 'error':
|
|
231
109
|
failed++;
|
|
232
110
|
break;
|
|
233
111
|
}
|
|
234
112
|
}
|
|
235
113
|
savePushLog(pushLog);
|
|
114
|
+
if (deletedWorklogIds.size > 0) {
|
|
115
|
+
removeTombstonesByWorklogIds(deletedWorklogIds);
|
|
116
|
+
}
|
|
236
117
|
return { posted, updated, deleted, skipped, failed };
|
|
237
118
|
}
|
|
238
119
|
// ─── Mark daily logs as pushed ───────────────────────────────────────────
|
|
120
|
+
/** Dates in [from, to] whose local day file exists — the stray-delete guard. */
|
|
121
|
+
function collectDatesWithData(from, to) {
|
|
122
|
+
const dates = new Set();
|
|
123
|
+
const current = new Date(from + 'T12:00:00Z');
|
|
124
|
+
const end = new Date(to + 'T12:00:00Z');
|
|
125
|
+
while (current <= end) {
|
|
126
|
+
const date = current.toISOString().slice(0, 10);
|
|
127
|
+
if (readDailyLog(date))
|
|
128
|
+
dates.add(date);
|
|
129
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
130
|
+
}
|
|
131
|
+
return dates;
|
|
132
|
+
}
|
|
239
133
|
function markDaysPushed(from, to) {
|
|
240
134
|
const current = new Date(from + 'T12:00:00Z');
|
|
241
135
|
const end = new Date(to + 'T12:00:00Z');
|
|
@@ -255,7 +149,7 @@ function markDaysPushed(from, to) {
|
|
|
255
149
|
}
|
|
256
150
|
/** Full push pipeline: build report → resolve Jira → fetch Tempo → plan → execute */
|
|
257
151
|
export async function runPush(options) {
|
|
258
|
-
const { from, to, commit, config, secrets, filePath } = options;
|
|
152
|
+
const { from, to, commit, config, secrets, filePath, force } = options;
|
|
259
153
|
// Step 1: Build or load report
|
|
260
154
|
let report;
|
|
261
155
|
if (filePath) {
|
|
@@ -268,7 +162,15 @@ export async function runPush(options) {
|
|
|
268
162
|
report = buildReport(from, to, config);
|
|
269
163
|
console.log(`Built report: ${report.length} entries (${from} → ${to})`);
|
|
270
164
|
}
|
|
271
|
-
|
|
165
|
+
// Local deletions may still need Tempo-side propagation even when the
|
|
166
|
+
// report is empty (a fully cleared pushed day, tombstoned entries).
|
|
167
|
+
const pushLog = loadPushLog();
|
|
168
|
+
const rangeTombstones = loadTombstones().filter(t => t.date >= from && t.date <= to);
|
|
169
|
+
const hasRangeOwnership = Object.keys(pushLog).some(k => {
|
|
170
|
+
const date = k.slice(0, 10);
|
|
171
|
+
return date >= from && date <= to;
|
|
172
|
+
});
|
|
173
|
+
if (report.length === 0 && rangeTombstones.length === 0 && !hasRangeOwnership) {
|
|
272
174
|
return { dryRun: !commit, plan: [], result: { posted: 0, updated: 0, deleted: 0, skipped: 0, failed: 0 } };
|
|
273
175
|
}
|
|
274
176
|
// Step 2: Resolve Jira issue IDs
|
|
@@ -288,20 +190,52 @@ export async function runPush(options) {
|
|
|
288
190
|
console.log(`Fetching Tempo worklogs (${from} → ${to})...`);
|
|
289
191
|
const tempoWorklogs = await tempoClient.getUserWorklogs(accountId, from, to);
|
|
290
192
|
console.log(`Found ${tempoWorklogs.length} existing worklog(s)`);
|
|
291
|
-
// Step 4: Build plan
|
|
292
|
-
|
|
293
|
-
const
|
|
193
|
+
// Step 4: Build plan. Tombstones whose worklog is already gone from Tempo
|
|
194
|
+
// (deleted remotely too) have nothing left to do — purge them silently.
|
|
195
|
+
const aliveIds = new Set(tempoWorklogs.map(w => w.tempoWorklogId));
|
|
196
|
+
const deadTombstones = rangeTombstones.filter(t => !aliveIds.has(t.tempoWorklogId));
|
|
197
|
+
if (deadTombstones.length > 0) {
|
|
198
|
+
removeTombstonesByWorklogIds(new Set(deadTombstones.map(t => t.tempoWorklogId)));
|
|
199
|
+
}
|
|
200
|
+
const plan = buildPushPlan(report, jiraMap, pushLog, tempoWorklogs, {
|
|
201
|
+
tombstones: rangeTombstones.filter(t => aliveIds.has(t.tempoWorklogId)),
|
|
202
|
+
from,
|
|
203
|
+
to,
|
|
204
|
+
datesWithData: collectDatesWithData(from, to),
|
|
205
|
+
});
|
|
294
206
|
if (!commit) {
|
|
295
207
|
return { dryRun: true, plan };
|
|
296
208
|
}
|
|
209
|
+
// Conflict gate: "local wins" is a choice, not a default. A commit push
|
|
210
|
+
// that would overwrite Tempo-side edits stops here until the caller
|
|
211
|
+
// explicitly forces it — nothing (conflicted or not) is executed.
|
|
212
|
+
if (!force) {
|
|
213
|
+
const conflicted = plan.filter(e => e.conflict);
|
|
214
|
+
if (conflicted.length > 0) {
|
|
215
|
+
console.log(`Push blocked: ${conflicted.length} worklog(s) edited in Tempo since our push.`);
|
|
216
|
+
return { dryRun: false, plan, blockedByConflicts: true };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
297
219
|
// Step 5: Execute
|
|
298
|
-
const actionable = plan.filter(e => e.action === 'create' || e.action === 'update');
|
|
220
|
+
const actionable = plan.filter(e => e.action === 'create' || e.action === 'update' || e.action === 'delete');
|
|
299
221
|
if (actionable.length === 0) {
|
|
300
222
|
console.log('Nothing to push.');
|
|
223
|
+
// Parity with Tempo is still a successful sync — seal the days, or an
|
|
224
|
+
// edited-then-reverted day stays Outdated forever (no mutation ever
|
|
225
|
+
// triggers the seal below). Plan errors (unresolved Jira) block it.
|
|
226
|
+
if (!plan.some(e => e.action === 'error')) {
|
|
227
|
+
markDaysPushed(from, to);
|
|
228
|
+
}
|
|
301
229
|
return { dryRun: false, plan, result: { posted: 0, updated: 0, deleted: 0, skipped: 0, failed: 0 } };
|
|
302
230
|
}
|
|
303
231
|
console.log(`Executing ${actionable.length} mutation(s)...`);
|
|
304
232
|
const result = await executePlan(plan, tempoClient, accountId);
|
|
233
|
+
// Tempo-side state changed: approval cache is stale; worklog snapshots are
|
|
234
|
+
// refetched right away so month statuses turn diff-based after every push.
|
|
235
|
+
if (result.posted > 0 || result.updated > 0 || result.deleted > 0) {
|
|
236
|
+
invalidateApprovalCache();
|
|
237
|
+
await refreshSnapshotsInRange(from, to, secrets, accountId);
|
|
238
|
+
}
|
|
305
239
|
// Seal the day only on a clean push. A failed mutation (network, Jira limit, etc.)
|
|
306
240
|
// must NOT mark the day pushed, or it would silently drop out of future syncs.
|
|
307
241
|
if (result.failed === 0) {
|
|
@@ -312,12 +246,4 @@ export async function runPush(options) {
|
|
|
312
246
|
}
|
|
313
247
|
return { dryRun: false, plan, result };
|
|
314
248
|
}
|
|
315
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
316
|
-
function formatHours(seconds) {
|
|
317
|
-
const hours = seconds / 3600;
|
|
318
|
-
const rounded1 = parseFloat(hours.toFixed(1));
|
|
319
|
-
if (Math.abs(hours - rounded1) < 0.01)
|
|
320
|
-
return `${hours.toFixed(1)}h`;
|
|
321
|
-
return `${hours.toFixed(2)}h`;
|
|
322
|
-
}
|
|
323
249
|
//# sourceMappingURL=tempo-pusher.js.map
|