@knpkv/jira-clockify 1.4.1 → 1.6.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 +105 -22
- package/dist/package.json +1 -1
- package/dist/src/agent/schedule.d.ts +6 -5
- package/dist/src/agent/schedule.d.ts.map +1 -1
- package/dist/src/agent/schedule.js +117 -102
- package/dist/src/agent/schedule.js.map +1 -1
- package/dist/src/agent/sessions.d.ts +27 -33
- package/dist/src/agent/sessions.d.ts.map +1 -1
- package/dist/src/agent/sessions.js +121 -83
- package/dist/src/agent/sessions.js.map +1 -1
- package/dist/src/agent/writePlanning.d.ts +2 -0
- package/dist/src/agent/writePlanning.d.ts.map +1 -1
- package/dist/src/agent/writePlanning.js +4 -1
- package/dist/src/agent/writePlanning.js.map +1 -1
- package/dist/src/bin.js +16 -5
- package/dist/src/bin.js.map +1 -1
- package/dist/src/cli/CommandFailed.d.ts +27 -0
- package/dist/src/cli/CommandFailed.d.ts.map +1 -0
- package/dist/src/cli/CommandFailed.js +25 -0
- package/dist/src/cli/CommandFailed.js.map +1 -0
- package/dist/src/cli/agentWrite.d.ts.map +1 -1
- package/dist/src/cli/agentWrite.js +12 -5
- package/dist/src/cli/agentWrite.js.map +1 -1
- package/dist/src/cli/auth.d.ts +59 -2
- package/dist/src/cli/auth.d.ts.map +1 -1
- package/dist/src/cli/auth.js +151 -106
- package/dist/src/cli/auth.js.map +1 -1
- package/dist/src/cli/config.d.ts +4 -3
- package/dist/src/cli/config.d.ts.map +1 -1
- package/dist/src/cli/config.js +73 -31
- package/dist/src/cli/config.js.map +1 -1
- package/dist/src/cli/fetchTicket.d.ts +3 -3
- package/dist/src/cli/fetchTicket.d.ts.map +1 -1
- package/dist/src/cli/fetchTicket.js +7 -7
- package/dist/src/cli/fetchTicket.js.map +1 -1
- package/dist/src/cli/layers.d.ts +6 -5
- package/dist/src/cli/layers.d.ts.map +1 -1
- package/dist/src/cli/layers.js +16 -15
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/list.d.ts +3 -2
- package/dist/src/cli/list.d.ts.map +1 -1
- package/dist/src/cli/list.js +11 -5
- package/dist/src/cli/list.js.map +1 -1
- package/dist/src/cli/reconcile.d.ts +18 -3
- package/dist/src/cli/reconcile.d.ts.map +1 -1
- package/dist/src/cli/reconcile.js +73 -23
- package/dist/src/cli/reconcile.js.map +1 -1
- package/dist/src/cli/root.d.ts.map +1 -1
- package/dist/src/cli/root.js +3 -3
- package/dist/src/cli/root.js.map +1 -1
- package/dist/src/cli/setup.d.ts +14 -4
- package/dist/src/cli/setup.d.ts.map +1 -1
- package/dist/src/cli/setup.js +45 -94
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/timer/discard.d.ts +1 -1
- package/dist/src/cli/timer/discard.d.ts.map +1 -1
- package/dist/src/cli/timer/discard.js +2 -1
- package/dist/src/cli/timer/discard.js.map +1 -1
- package/dist/src/cli/timer/log.d.ts +1 -1
- package/dist/src/cli/timer/log.d.ts.map +1 -1
- package/dist/src/cli/timer/log.js +2 -1
- package/dist/src/cli/timer/log.js.map +1 -1
- package/dist/src/cli/timer/start.d.ts +2 -1
- package/dist/src/cli/timer/start.d.ts.map +1 -1
- package/dist/src/cli/timer/start.js +6 -8
- package/dist/src/cli/timer/start.js.map +1 -1
- package/dist/src/cli/timer/stop.d.ts +1 -1
- package/dist/src/cli/timer/stop.d.ts.map +1 -1
- package/dist/src/cli/timer.d.ts.map +1 -1
- package/dist/src/cli/timer.js +8 -1
- package/dist/src/cli/timer.js.map +1 -1
- package/dist/src/cli/unknownCommand.d.ts +24 -0
- package/dist/src/cli/unknownCommand.d.ts.map +1 -0
- package/dist/src/cli/unknownCommand.js +84 -0
- package/dist/src/cli/unknownCommand.js.map +1 -0
- package/dist/src/cli/watch.d.ts +2 -2
- package/dist/src/cli/watch.d.ts.map +1 -1
- package/dist/src/services/AgentSessionReader.d.ts +26 -12
- package/dist/src/services/AgentSessionReader.d.ts.map +1 -1
- package/dist/src/services/AgentSessionReader.js +163 -69
- package/dist/src/services/AgentSessionReader.js.map +1 -1
- package/dist/src/services/CodexTranscript.d.ts +50 -13
- package/dist/src/services/CodexTranscript.d.ts.map +1 -1
- package/dist/src/services/CodexTranscript.js +49 -13
- package/dist/src/services/CodexTranscript.js.map +1 -1
- package/dist/src/services/ConfigService.d.ts +4 -0
- package/dist/src/services/ConfigService.d.ts.map +1 -1
- package/dist/src/services/ConfigService.js +6 -2
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/IssueFacts.d.ts +2 -2
- package/dist/src/services/IssueFacts.d.ts.map +1 -1
- package/dist/src/services/IssueFacts.js +5 -5
- package/dist/src/services/IssueFacts.js.map +1 -1
- package/dist/src/services/JiraAccess.d.ts +140 -0
- package/dist/src/services/JiraAccess.d.ts.map +1 -0
- package/dist/src/services/JiraAccess.js +204 -0
- package/dist/src/services/JiraAccess.js.map +1 -0
- package/dist/src/services/ProviderSnapshots.d.ts +41 -0
- package/dist/src/services/ProviderSnapshots.d.ts.map +1 -0
- package/dist/src/services/ProviderSnapshots.js +71 -0
- package/dist/src/services/ProviderSnapshots.js.map +1 -0
- package/dist/src/services/ReconcileService.d.ts +12 -4
- package/dist/src/services/ReconcileService.d.ts.map +1 -1
- package/dist/src/services/ReconcileService.js +223 -113
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/SavedEntries.d.ts +24 -4
- package/dist/src/services/SavedEntries.d.ts.map +1 -1
- package/dist/src/services/SavedEntries.js +335 -19
- package/dist/src/services/SavedEntries.js.map +1 -1
- package/dist/src/services/SessionAttributor.d.ts +12 -0
- package/dist/src/services/SessionAttributor.d.ts.map +1 -1
- package/dist/src/services/SessionAttributor.js +13 -1
- package/dist/src/services/SessionAttributor.js.map +1 -1
- package/dist/src/services/SessionLine.d.ts +29 -0
- package/dist/src/services/SessionLine.d.ts.map +1 -0
- package/dist/src/services/SessionLine.js +8 -0
- package/dist/src/services/SessionLine.js.map +1 -0
- package/dist/src/services/SourceLedger.d.ts +84 -1
- package/dist/src/services/SourceLedger.d.ts.map +1 -1
- package/dist/src/services/SourceLedger.js +178 -12
- package/dist/src/services/SourceLedger.js.map +1 -1
- package/dist/src/services/TicketRanking.d.ts +39 -0
- package/dist/src/services/TicketRanking.d.ts.map +1 -0
- package/dist/src/services/TicketRanking.js +87 -0
- package/dist/src/services/TicketRanking.js.map +1 -0
- package/dist/src/services/TimerService.d.ts +3 -3
- package/dist/src/services/TimerService.d.ts.map +1 -1
- package/dist/src/services/TimerService.js +6 -17
- package/dist/src/services/TimerService.js.map +1 -1
- package/dist/src/services/internal/retryTransport.d.ts +9 -0
- package/dist/src/services/internal/retryTransport.d.ts.map +1 -0
- package/dist/src/services/internal/retryTransport.js +15 -0
- package/dist/src/services/internal/retryTransport.js.map +1 -0
- package/dist/src/testing/fakeHeadless.d.ts +12 -1
- package/dist/src/testing/fakeHeadless.d.ts.map +1 -1
- package/dist/src/testing/fakeHeadless.js +83 -10
- package/dist/src/testing/fakeHeadless.js.map +1 -1
- package/dist/src/tui/App.js +1 -1
- package/dist/src/tui/atoms/runtime.d.ts +1 -1
- package/dist/src/tui/atoms/runtime.d.ts.map +1 -1
- package/dist/src/utils/hints.d.ts +4 -2
- package/dist/src/utils/hints.d.ts.map +1 -1
- package/dist/src/utils/hints.js +4 -2
- package/dist/src/utils/hints.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/skills/jcf/SKILL.md +9 -6
|
@@ -21,36 +21,40 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
24
|
-
import { ClockifyApi
|
|
25
|
-
import { JiraApiClient
|
|
26
|
-
import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
|
|
24
|
+
import { ClockifyApi } from "@knpkv/clockify-api-client";
|
|
25
|
+
import { JiraApiClient } from "@knpkv/jira-api-client";
|
|
27
26
|
import * as Clock from "effect/Clock";
|
|
28
27
|
import * as Context from "effect/Context";
|
|
29
28
|
import * as Data from "effect/Data";
|
|
30
29
|
import * as Effect from "effect/Effect";
|
|
31
30
|
import * as FileSystem from "effect/FileSystem";
|
|
32
|
-
import * as HttpClient from "effect/http/HttpClient";
|
|
33
31
|
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
34
32
|
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
35
33
|
import * as Layer from "effect/Layer";
|
|
34
|
+
import * as Option from "effect/Option";
|
|
36
35
|
import * as Path from "effect/Path";
|
|
37
36
|
import * as Predicate from "effect/Predicate";
|
|
38
|
-
import * as Redacted from "effect/Redacted";
|
|
39
37
|
import * as Schema from "effect/Schema";
|
|
40
|
-
import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, splitCredits } from "../agent/sessions.js";
|
|
38
|
+
import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, mergeSpansWithinDays, splitCredits, splitSessionAttribution } from "../agent/sessions.js";
|
|
41
39
|
import * as SourceConsumption from "../agent/sourceConsumption.js";
|
|
42
40
|
import * as WriterGuard from "../cli/writerGuard.js";
|
|
41
|
+
import { CONNECT_JIRA_COMMAND, NOT_LOGGED_IN_HINT } from "../utils/hints.js";
|
|
43
42
|
import { localDay, nextLocalMidnight, splitIntervalByLocalDay } from "../utils/time.js";
|
|
44
43
|
import { AgentSessionReader } from "./AgentSessionReader.js";
|
|
45
|
-
import { ClockifyAuth } from "./ClockifyAuth.js";
|
|
46
44
|
import { ConfigService } from "./ConfigService.js";
|
|
47
45
|
import { HomeDirectory } from "./HomeDirectory.js";
|
|
48
46
|
import { postJiraWorklog } from "./internal/JiraWorklogPost.js";
|
|
47
|
+
import { retryTransport } from "./internal/retryTransport.js";
|
|
48
|
+
import { JiraAccess } from "./JiraAccess.js";
|
|
49
49
|
import { readJiraDeletionEvidence } from "./ProviderDeletion.js";
|
|
50
|
+
import { make as makeProviderSnapshots } from "./ProviderSnapshots.js";
|
|
50
51
|
import { jiraWorklogDescription } from "./SavedEntries.js";
|
|
51
52
|
import { SessionAttributor } from "./SessionAttributor.js";
|
|
52
53
|
import { SourceLedger } from "./SourceLedger.js";
|
|
54
|
+
import { candidateFact, readRankingFacts, ticketPriority } from "./TicketRanking.js";
|
|
53
55
|
import { TimerService } from "./TimerService.js";
|
|
56
|
+
/** Concurrent Jira worklog reads per tally. Bounded so a large week does not trip rate limits. */
|
|
57
|
+
const JIRA_WORKLOG_READ_CONCURRENCY = 8;
|
|
54
58
|
export { bothSides } from "../agent/sessions.js";
|
|
55
59
|
const mergeIntervals = (intervals) => {
|
|
56
60
|
const sorted = [...intervals].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
|
|
@@ -137,6 +141,23 @@ const batchRequests = (requests) => {
|
|
|
137
141
|
batches.push(current);
|
|
138
142
|
return batches;
|
|
139
143
|
};
|
|
144
|
+
/** Raw ignored presence, unioned per ticket/day so parallel sessions on one key never duplicate it. */
|
|
145
|
+
const ignoredCredits = (evidence, ignoredTickets) => {
|
|
146
|
+
const windowsByTicket = new Map();
|
|
147
|
+
for (const window of evidence) {
|
|
148
|
+
if (window.ticketKey === null || !ignoredTickets.has(window.ticketKey))
|
|
149
|
+
continue;
|
|
150
|
+
windowsByTicket.set(window.ticketKey, [...(windowsByTicket.get(window.ticketKey) ?? []), ...window.spans]);
|
|
151
|
+
}
|
|
152
|
+
return [...windowsByTicket].flatMap(([ticketKey, spans]) => {
|
|
153
|
+
const secondsByDay = new Map();
|
|
154
|
+
for (const span of mergeSpansWithinDays(spans)) {
|
|
155
|
+
const day = localDay(new Date(span.startMs));
|
|
156
|
+
secondsByDay.set(day, (secondsByDay.get(day) ?? 0) + (span.endMs - span.startMs) / 1000);
|
|
157
|
+
}
|
|
158
|
+
return [...secondsByDay].map(([day, seconds]) => ({ ticketKey, day, seconds: Math.floor(seconds) }));
|
|
159
|
+
}).sort((a, b) => a.ticketKey.localeCompare(b.ticketKey) || a.day.localeCompare(b.day));
|
|
160
|
+
};
|
|
140
161
|
/**
|
|
141
162
|
* Provider totals that may size a write. Compatibility Jira rows remain visible, but cannot settle
|
|
142
163
|
* an executable gap until the same credential and account have been verified.
|
|
@@ -307,10 +328,8 @@ const toRawWorklog = (value) => {
|
|
|
307
328
|
};
|
|
308
329
|
};
|
|
309
330
|
export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
310
|
-
const clockifyAuth = yield* ClockifyAuth;
|
|
311
331
|
const jira = yield* JiraApiClient;
|
|
312
|
-
const
|
|
313
|
-
const httpClient = yield* HttpClient.HttpClient;
|
|
332
|
+
const jiraAccess = yield* JiraAccess;
|
|
314
333
|
const config = yield* ConfigService;
|
|
315
334
|
const fileSystem = yield* FileSystem.FileSystem;
|
|
316
335
|
const path = yield* Path.Path;
|
|
@@ -320,68 +339,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
320
339
|
const attributor = yield* SessionAttributor;
|
|
321
340
|
const sourceLedger = yield* SourceLedger;
|
|
322
341
|
const provideGuard = (effect) => effect.pipe(Effect.provideService(ConfigService, config), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path));
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
const auth = yield* getAuth;
|
|
327
|
-
const endpoint = yield* Effect.try({
|
|
328
|
-
try: () => new URL(auth.baseUrl),
|
|
329
|
-
catch: (cause) => new ReconcileError({ message: "Configured Clockify endpoint is invalid", cause })
|
|
330
|
-
});
|
|
331
|
-
if (!["https:", "http:"].includes(endpoint.protocol) || endpoint.username !== "" ||
|
|
332
|
-
endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "")
|
|
333
|
-
return yield* new ReconcileError({ message: "Configured Clockify endpoint is invalid" });
|
|
334
|
-
const canonicalEndpoint = `${endpoint.origin}${endpoint.pathname.replace(/\/+$/u, "")}`;
|
|
335
|
-
const pinnedAuth = { ...auth, baseUrl: canonicalEndpoint };
|
|
336
|
-
const client = makeClockifyApi(httpClient, pinnedAuth);
|
|
337
|
-
const user = yield* client.getLoggedUser(undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Cannot verify the Clockify account", cause })));
|
|
338
|
-
if (user.id === "" || auth.userId !== user.id || auth.workspaceId === "" || auth.baseUrl === "") {
|
|
339
|
-
return yield* new ReconcileError({ message: "Configured Clockify account does not match the credential" });
|
|
340
|
-
}
|
|
341
|
-
return {
|
|
342
|
-
auth: pinnedAuth,
|
|
343
|
-
client,
|
|
344
|
-
scope: JSON.stringify(["clockify-v3", canonicalEndpoint, auth.workspaceId, user.id]),
|
|
345
|
-
legacyScope: JSON.stringify([auth.workspaceId, user.id])
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
/** Bind verification and worklog POST to one OAuth credential and selected site. */
|
|
349
|
-
const jiraWriteState = Effect.gen(function* () {
|
|
350
|
-
const login = yield* Effect.result(jiraAuth.isLoggedIn());
|
|
351
|
-
if (login._tag === "Failure")
|
|
352
|
-
return { availability: "unverified", snapshot: null };
|
|
353
|
-
if (!login.success)
|
|
354
|
-
return { availability: "not-logged-in", snapshot: null };
|
|
355
|
-
const tokenResult = yield* Effect.result(jiraAuth.getAccessToken());
|
|
356
|
-
const profileResult = yield* Effect.result(jiraAuth.getActiveProfile());
|
|
357
|
-
if (tokenResult._tag === "Failure" || profileResult._tag === "Failure") {
|
|
358
|
-
return { availability: "unverified", snapshot: null };
|
|
359
|
-
}
|
|
360
|
-
const token = tokenResult.success;
|
|
361
|
-
const profile = profileResult.success;
|
|
362
|
-
if (profile === null || profile.token.cloud_id === "" || profile.token.site_url === "" ||
|
|
363
|
-
profile.token.access_token !== Redacted.value(token))
|
|
364
|
-
return { availability: "unverified", snapshot: null };
|
|
365
|
-
const client = makeJiraApi(httpClient, {
|
|
366
|
-
baseUrl: "",
|
|
367
|
-
auth: { type: "oauth2", accessToken: token, cloudId: profile.token.cloud_id }
|
|
368
|
-
});
|
|
369
|
-
const live = yield* client.getCurrentUser({}).pipe(Effect.orElseSucceed(() => null));
|
|
370
|
-
if (live?.accountId === undefined || live.accountId === "") {
|
|
371
|
-
return { availability: "unverified", snapshot: null };
|
|
372
|
-
}
|
|
373
|
-
return {
|
|
374
|
-
availability: "verified",
|
|
375
|
-
snapshot: {
|
|
376
|
-
client,
|
|
377
|
-
ledgerScope: JSON.stringify([profile.token.cloud_id, live.accountId]),
|
|
378
|
-
heldScope: JSON.stringify([profile.token.cloud_id, profile.token.site_url, live.accountId]),
|
|
379
|
-
accountId: live.accountId,
|
|
380
|
-
cloudId: profile.token.cloud_id,
|
|
381
|
-
siteUrl: profile.token.site_url
|
|
382
|
-
}
|
|
383
|
-
};
|
|
384
|
-
});
|
|
342
|
+
const providerSnapshots = yield* makeProviderSnapshots;
|
|
343
|
+
const clockifyWriteSnapshot = providerSnapshots.clockify.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
344
|
+
const jiraWriteState = providerSnapshots.jira;
|
|
385
345
|
const jiraWriteSnapshot = jiraWriteState.pipe(Effect.map((state) => state.snapshot));
|
|
386
346
|
/**
|
|
387
347
|
* How many entries to ask Clockify for at a time, and how many pages to accept.
|
|
@@ -472,9 +432,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
472
432
|
let nextStartAt = 0;
|
|
473
433
|
let expectedTotal;
|
|
474
434
|
for (;;) {
|
|
475
|
-
const page = yield* client.getIssueWorklog(issueKey, {
|
|
435
|
+
const page = yield* retryTransport(client.getIssueWorklog(issueKey, {
|
|
476
436
|
params: { startAt: nextStartAt, startedAfter, startedBefore }
|
|
477
|
-
}).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
|
|
437
|
+
})).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
|
|
478
438
|
const { maxResults, startAt, total, worklogs: entries } = page;
|
|
479
439
|
if (startAt !== nextStartAt ||
|
|
480
440
|
maxResults === undefined || !Number.isSafeInteger(maxResults) || maxResults < 0 ||
|
|
@@ -507,13 +467,21 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
507
467
|
}
|
|
508
468
|
});
|
|
509
469
|
// Tally the current user's Jira worklogs in the period by (ticket, day).
|
|
510
|
-
const jiraTally = (period, requiredIssueKeys = [], pinned = undefined
|
|
470
|
+
const jiraTally = (period, requiredIssueKeys = [], pinned = undefined,
|
|
471
|
+
/**
|
|
472
|
+
* `window` searches every issue I logged in the period and adds the required keys;
|
|
473
|
+
* `required-only` reads just the required keys, to top up a tally that already searched.
|
|
474
|
+
*/
|
|
475
|
+
scope = "window") => Effect.gen(function* () {
|
|
511
476
|
// A verified snapshot pins the account, site and credential-backed client for every page.
|
|
512
477
|
// Falling back is only for a side without a usable write credential; that side cannot be
|
|
513
478
|
// written because its source scope remains null, but independent providers may still proceed.
|
|
514
479
|
const snapshot = pinned === undefined ? yield* jiraWriteSnapshot : pinned;
|
|
515
480
|
const client = snapshot?.client ?? jira;
|
|
516
|
-
const
|
|
481
|
+
const connection = yield* jiraAccess.connection.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
482
|
+
const cachedAccountId = Option.isSome(connection) && connection.value.accountId !== ""
|
|
483
|
+
? connection.value.accountId
|
|
484
|
+
: undefined;
|
|
517
485
|
if (snapshot !== null && snapshot !== undefined && cachedAccountId !== snapshot.accountId) {
|
|
518
486
|
return yield* new ReconcileError({
|
|
519
487
|
message: "The cached Jira account does not match the verified credential"
|
|
@@ -522,7 +490,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
522
490
|
const accountId = snapshot?.accountId ?? cachedAccountId;
|
|
523
491
|
if (accountId === undefined || accountId === "") {
|
|
524
492
|
return yield* new ReconcileError({
|
|
525
|
-
message:
|
|
493
|
+
message: Option.isNone(connection)
|
|
494
|
+
? NOT_LOGGED_IN_HINT
|
|
495
|
+
: `Jira did not say which account is connected. Run ${CONNECT_JIRA_COMMAND} again.`
|
|
526
496
|
});
|
|
527
497
|
}
|
|
528
498
|
const fromMs = period.from.getTime();
|
|
@@ -542,19 +512,19 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
542
512
|
// "Jira is short by the whole day" — which `watch` would then post on top of hours Jira
|
|
543
513
|
// already holds. A window of more than a hundred distinct issues is an ordinary week.
|
|
544
514
|
const jql = `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`;
|
|
545
|
-
const searchPage = (pageToken) => client.searchIssuesUsingJql({
|
|
515
|
+
const searchPage = (pageToken) => retryTransport(client.searchIssuesUsingJql({
|
|
546
516
|
params: {
|
|
547
517
|
jql,
|
|
548
518
|
maxResults: 100,
|
|
549
519
|
fields: ["key"],
|
|
550
520
|
...((pageToken !== undefined) && { nextPageToken: pageToken })
|
|
551
521
|
}
|
|
552
|
-
}).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
|
|
522
|
+
})).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
|
|
553
523
|
const searched = [];
|
|
554
524
|
let pageToken = undefined;
|
|
555
525
|
// Bounded so a server that keeps handing back a token cannot spin here for ever; hitting the
|
|
556
526
|
// bound fails the run rather than proceeding on a partial tally.
|
|
557
|
-
for (let page = 0; page < 50; page++) {
|
|
527
|
+
for (let page = 0; scope === "window" && page < 50; page++) {
|
|
558
528
|
const result = yield* searchPage(pageToken);
|
|
559
529
|
const issues = Predicate.isObject(result) ? result["issues"] : undefined;
|
|
560
530
|
// A page with no `issues` at all is not an empty page. The generated success schema makes
|
|
@@ -601,8 +571,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
601
571
|
}
|
|
602
572
|
const toMs = period.to.getTime();
|
|
603
573
|
const tally = [];
|
|
604
|
-
|
|
605
|
-
|
|
574
|
+
// Every issue's worklogs are read before any is tallied, a few at a time: a week of forty
|
|
575
|
+
// tickets read one after another made every confirmation wait on forty round trips. Tallying
|
|
576
|
+
// stays in issue order, so the result is the same as a sequential read.
|
|
577
|
+
const worklogsByIssue = yield* Effect.forEach(issueKeys, (issueKey) => readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs), { concurrency: JIRA_WORKLOG_READ_CONCURRENCY });
|
|
578
|
+
for (const [issueIndex, issueKey] of issueKeys.entries()) {
|
|
579
|
+
const worklogs = worklogsByIssue[issueIndex] ?? [];
|
|
606
580
|
for (const wl of worklogs) {
|
|
607
581
|
// Only this user's worklogs (the JQL narrows issues, not individual worklog authors).
|
|
608
582
|
const author = wl.author?.accountId;
|
|
@@ -641,9 +615,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
641
615
|
// A side that is out is not called at all. That is the point of the option for someone who
|
|
642
616
|
// tracks in one system: no Clockify workspace to configure, no Jira login to keep alive, and
|
|
643
617
|
// no request whose failure could stop a run that never needed it.
|
|
644
|
-
const jiraSideRead = sides.jira
|
|
645
|
-
?
|
|
646
|
-
:
|
|
618
|
+
const jiraSideRead = !sides.jira
|
|
619
|
+
? Effect.succeed([])
|
|
620
|
+
: options?.jiraPreRead !== undefined
|
|
621
|
+
? Effect.succeed(options.jiraPreRead)
|
|
622
|
+
: jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot);
|
|
647
623
|
const [clockifySide, jiraSide] = yield* Effect.all([
|
|
648
624
|
sides.clockify
|
|
649
625
|
? clockifyTally(period, options?.clockifySnapshot)
|
|
@@ -1118,7 +1094,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1118
1094
|
// `attributeSession` remains the single place the precedence is actually applied.
|
|
1119
1095
|
const needsAgent = options.mode === "deterministic" ?
|
|
1120
1096
|
[] :
|
|
1121
|
-
sessions.filter((session) => deterministicAttribution(session,
|
|
1097
|
+
sessions.filter((session) => deterministicAttribution(session, options) === null &&
|
|
1122
1098
|
session.candidateKeys.length > 0);
|
|
1123
1099
|
const batches = batchRequests(needsAgent);
|
|
1124
1100
|
if (needsAgent.length > 0) {
|
|
@@ -1135,7 +1111,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1135
1111
|
const batchResults = yield* Effect.all(batches.map((batch, batchIndex) => attributor.attribute(batch.map((session) => ({
|
|
1136
1112
|
sessionId: session.sessionId,
|
|
1137
1113
|
candidateKeys: session.candidateKeys,
|
|
1138
|
-
digest: session.digest
|
|
1114
|
+
digest: session.digest,
|
|
1115
|
+
candidateFacts: new Map(session.candidateKeys.flatMap((key) => {
|
|
1116
|
+
const fact = options.candidateFact?.(key);
|
|
1117
|
+
return fact === undefined ? [] : [[key, fact]];
|
|
1118
|
+
}))
|
|
1139
1119
|
})), (activity) => options.onProgress({
|
|
1140
1120
|
_tag: "AgentActivity",
|
|
1141
1121
|
batch: batchIndex + 1,
|
|
@@ -1145,7 +1125,10 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1145
1125
|
const bySession = new Map(answers.map((answer) => [answer.sessionId, answer.choice]));
|
|
1146
1126
|
return batch.map((session) => {
|
|
1147
1127
|
const declined = { _tag: "None" };
|
|
1148
|
-
const
|
|
1128
|
+
const answer = bySession.get(session.sessionId) ?? declined;
|
|
1129
|
+
const choice = answer._tag === "Chosen" && options.ignoredTickets.has(answer.ticketKey)
|
|
1130
|
+
? declined
|
|
1131
|
+
: answer;
|
|
1149
1132
|
return {
|
|
1150
1133
|
session,
|
|
1151
1134
|
choice,
|
|
@@ -1200,7 +1183,8 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1200
1183
|
const attributions = sessions.map((session) => attributeSession(session, {
|
|
1201
1184
|
standingMap: options.standingMap,
|
|
1202
1185
|
agentChoice: chosen.get(session.sessionId) ?? null,
|
|
1203
|
-
confidenceFloor: options.confidenceFloor
|
|
1186
|
+
confidenceFloor: options.confidenceFloor,
|
|
1187
|
+
ignoredTickets: options.ignoredTickets
|
|
1204
1188
|
}));
|
|
1205
1189
|
return { attributions, attributorAvailable, attributorCalls: batches.length };
|
|
1206
1190
|
});
|
|
@@ -1224,15 +1208,40 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1224
1208
|
return requests.map((request) => byId.get(request.id) ?? null);
|
|
1225
1209
|
});
|
|
1226
1210
|
/** Both first reads and post-write refreshes subtract live totals through the same path. */
|
|
1227
|
-
const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period,
|
|
1211
|
+
const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, cached, options,
|
|
1212
|
+
/** A Jira write state the caller already resolved, so one read never verifies twice. */
|
|
1213
|
+
pinnedJiraState,
|
|
1214
|
+
/**
|
|
1215
|
+
* A Jira tally taken under `pinnedJiraState` earlier in the same read, with the issue keys it
|
|
1216
|
+
* was required to cover. Reused only when it covers every key this refresh needs.
|
|
1217
|
+
*/
|
|
1218
|
+
preReadJira) {
|
|
1219
|
+
const ignoredTickets = new Set((yield* config.get).sessionIgnoredTickets);
|
|
1220
|
+
const ignored = [...new Map([
|
|
1221
|
+
...ignoredCredits(cached.sessionEvidence ?? [], ignoredTickets),
|
|
1222
|
+
...cached.ignored.filter((entry) => ignoredTickets.has(entry.ticketKey))
|
|
1223
|
+
].map((entry) => [`${entry.ticketKey}:${entry.day}`, entry])).values()];
|
|
1224
|
+
const previous = {
|
|
1225
|
+
...cached,
|
|
1226
|
+
attributed: cached.attributed.filter((row) => !ignoredTickets.has(row.ticketKey)),
|
|
1227
|
+
withheld: cached.withheld.filter((row) => !ignoredTickets.has(row.ticketKey)),
|
|
1228
|
+
ignoredTickets: [...ignoredTickets].sort(),
|
|
1229
|
+
ignored
|
|
1230
|
+
};
|
|
1228
1231
|
const { sides } = previous;
|
|
1232
|
+
const writeBlocked = {};
|
|
1229
1233
|
const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1230
1234
|
const clockifySnapshot = sides.clockify ? yield* clockifyWriteSnapshot : null;
|
|
1231
1235
|
if (clockifySnapshot !== null) {
|
|
1232
|
-
yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.
|
|
1236
|
+
yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
|
|
1237
|
+
if (cause.reason !== "review-required")
|
|
1238
|
+
return Effect.fail(cause);
|
|
1239
|
+
writeBlocked.clockify = cause.message;
|
|
1240
|
+
return Effect.void;
|
|
1241
|
+
}), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1233
1242
|
}
|
|
1234
1243
|
const clockifyScope = clockifySnapshot?.scope ?? null;
|
|
1235
|
-
const jiraState = sides.jira ? yield* jiraWriteState : undefined;
|
|
1244
|
+
const jiraState = sides.jira ? pinnedJiraState ?? (yield* jiraWriteState) : undefined;
|
|
1236
1245
|
const jiraSnapshot = jiraState?.snapshot ?? null;
|
|
1237
1246
|
// Compatibility reads may still render Jira totals, but only one verified snapshot may grant
|
|
1238
1247
|
// durable source authority. Independent Clockify work remains executable while Jira is held.
|
|
@@ -1259,10 +1268,31 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1259
1268
|
...(options?.jiraIssueKeys ?? [])
|
|
1260
1269
|
])
|
|
1261
1270
|
];
|
|
1271
|
+
// Covered means read directly or found holding my time; anything else is read fresh, so reuse
|
|
1272
|
+
// is an optimization the result never depends on.
|
|
1273
|
+
const covered = new Set([
|
|
1274
|
+
...(preReadJira?.requiredKeys ?? []),
|
|
1275
|
+
...(preReadJira?.tally ?? []).map((row) => row.ticketKey)
|
|
1276
|
+
]);
|
|
1277
|
+
// Keys the pre-read did not cover — usually tickets the agent or a split chose — are read on
|
|
1278
|
+
// their own and merged. They held none of my time in the pre-read's search, so their rows
|
|
1279
|
+
// cannot duplicate it. If the top-up fails, the refresh falls back to a full read.
|
|
1280
|
+
const missingKeys = jiraIssueKeys.filter((key) => !covered.has(key));
|
|
1281
|
+
const topUp = preReadJira === undefined || pinnedJiraState === undefined || missingKeys.length === 0
|
|
1282
|
+
? undefined
|
|
1283
|
+
: yield* Effect.result(jiraTally(period, missingKeys, jiraSnapshot, "required-only"));
|
|
1284
|
+
const jiraPreRead = preReadJira === undefined || pinnedJiraState === undefined
|
|
1285
|
+
? undefined
|
|
1286
|
+
: missingKeys.length === 0
|
|
1287
|
+
? preReadJira.tally
|
|
1288
|
+
: topUp?._tag === "Success"
|
|
1289
|
+
? [...preReadJira.tally, ...topUp.success]
|
|
1290
|
+
: undefined;
|
|
1262
1291
|
const [{ recorded, unlinkedClockify }, excludedDays] = yield* Effect.all([
|
|
1263
1292
|
readRecorded(period, {
|
|
1264
1293
|
sides,
|
|
1265
1294
|
jiraIssueKeys,
|
|
1295
|
+
jiraPreRead,
|
|
1266
1296
|
clockifySnapshot: clockifySnapshot ?? undefined,
|
|
1267
1297
|
jiraSnapshot,
|
|
1268
1298
|
tolerateUnavailableJira: jiraState?.availability !== "verified"
|
|
@@ -1329,19 +1359,23 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1329
1359
|
scope,
|
|
1330
1360
|
fromMs: period.from.getTime(),
|
|
1331
1361
|
toMs: period.to.getTime()
|
|
1332
|
-
}, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.
|
|
1362
|
+
}, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
|
|
1363
|
+
if (cause.reason !== "review-required")
|
|
1364
|
+
return Effect.fail(cause);
|
|
1365
|
+
writeBlocked[provider] = cause.message;
|
|
1366
|
+
return Effect.void;
|
|
1367
|
+
}), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1333
1368
|
}
|
|
1334
1369
|
const verifiedLedger = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1335
|
-
if (previous.attributed.length > 0
|
|
1336
|
-
|
|
1370
|
+
if (previous.attributed.length > 0) {
|
|
1371
|
+
for (const entry of entries) {
|
|
1337
1372
|
const scope = entry.source === "clockify" ? clockifyScope : jiraScope;
|
|
1338
|
-
|
|
1373
|
+
if (scope !== null && entry.description !== null &&
|
|
1339
1374
|
SourceConsumption.markers(entry.description).length > 0 &&
|
|
1340
|
-
!verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
});
|
|
1375
|
+
!verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)) {
|
|
1376
|
+
writeBlocked[entry.source] = "An unlinked source marker needs private manual review before session writes";
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1345
1379
|
}
|
|
1346
1380
|
const sourceEntries = verifiedLedger.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope).flatMap((binding) => {
|
|
1347
1381
|
const entry = entries.find((candidate) => candidate.source === binding.provider && candidate.id === binding.entryId);
|
|
@@ -1357,6 +1391,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1357
1391
|
const executableRecorded = recordedForExecution(recorded, jiraState?.availability);
|
|
1358
1392
|
return {
|
|
1359
1393
|
...previous,
|
|
1394
|
+
writeBlocked,
|
|
1360
1395
|
proposals: buildSessionProposals(previous.attributed, executableRecorded, {
|
|
1361
1396
|
minimumSeconds: MINIMUM_PROPOSAL_SECONDS,
|
|
1362
1397
|
excludedDays: excludedDays.map((excluded) => excluded.day),
|
|
@@ -1366,7 +1401,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1366
1401
|
day: slice.day,
|
|
1367
1402
|
intervals: slice.entry === undefined ? [] : [{ entry: slice.entry }]
|
|
1368
1403
|
}))
|
|
1369
|
-
}),
|
|
1404
|
+
}).map((proposal) => ({ ...proposal, writeBlocked })),
|
|
1370
1405
|
recorded,
|
|
1371
1406
|
unlinkedClockify,
|
|
1372
1407
|
sourceEntries,
|
|
@@ -1378,13 +1413,72 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1378
1413
|
const proposeFromSessions = (period, options) => Effect.gen(function* () {
|
|
1379
1414
|
const onProgress = options?.onProgress ?? (() => Effect.void);
|
|
1380
1415
|
const cfg = yield* config.get;
|
|
1416
|
+
const ignoredTickets = new Set(cfg.sessionIgnoredTickets);
|
|
1417
|
+
const standingMap = expandedStandingMap(cfg.sessionTicketMap, home);
|
|
1381
1418
|
const sessions = yield* sessionReader.read(period).pipe(Effect.mapError((error) => new ReconcileError({ message: error.message, cause: error })));
|
|
1382
1419
|
yield* onProgress({ _tag: "SessionsRead", count: sessions.length });
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1420
|
+
const sides = options?.sides ?? bothSides;
|
|
1421
|
+
// Only a run about Jira reads Jira to rank: a Clockify-only week makes no Jira request. The
|
|
1422
|
+
// verified profile's client, so a sprint is never read from a different account's site.
|
|
1423
|
+
// Resolved once for the whole read: ranking and the first recorded refresh must see the
|
|
1424
|
+
// same verified authority, and resolving twice can observe two different answers. The pin
|
|
1425
|
+
// never outlives the read: every write path refreshes without it and verifies afresh.
|
|
1426
|
+
const jiraState = sides.jira ? yield* jiraWriteState : undefined;
|
|
1427
|
+
const rankingSnapshot = jiraState?.snapshot ?? undefined;
|
|
1428
|
+
// The Jira tally is read before attribution, so ranking and the attribution prompt see who
|
|
1429
|
+
// logged what, and the first refresh reuses it. Branch, path and standing targets and
|
|
1430
|
+
// ledger-bound tickets are required reads; any other candidate the tally lacks is unlogged.
|
|
1431
|
+
const preReadJira = rankingSnapshot === undefined ? undefined : yield* Effect.gen(function* () {
|
|
1432
|
+
const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1433
|
+
const requiredKeys = [
|
|
1434
|
+
...new Set([
|
|
1435
|
+
...sessions.flatMap((session) => {
|
|
1436
|
+
const target = deterministicAttribution(session, { standingMap });
|
|
1437
|
+
return target === null || ignoredTickets.has(target.ticketKey) ? [] : [target.ticketKey];
|
|
1438
|
+
}),
|
|
1439
|
+
...stored.bindings.filter((binding) => binding.provider === "jira" && binding.scope === rankingSnapshot.ledgerScope).map((binding) => binding.ticketKey)
|
|
1440
|
+
])
|
|
1441
|
+
];
|
|
1442
|
+
const tally = yield* Effect.result(jiraTally(period, requiredKeys, rankingSnapshot));
|
|
1443
|
+
return tally._tag === "Success" ? { tally: tally.success, requiredKeys } : undefined;
|
|
1444
|
+
});
|
|
1445
|
+
const ranking = rankingSnapshot !== undefined
|
|
1446
|
+
? yield* readRankingFacts((jql, nextPageToken) => retryTransport(rankingSnapshot.client.searchIssuesUsingJql({
|
|
1447
|
+
params: {
|
|
1448
|
+
jql,
|
|
1449
|
+
maxResults: 100,
|
|
1450
|
+
fields: ["key"],
|
|
1451
|
+
...((nextPageToken !== undefined) && { nextPageToken })
|
|
1452
|
+
}
|
|
1453
|
+
})), preReadJira?.tally)
|
|
1454
|
+
: {
|
|
1455
|
+
_tag: "Unknown",
|
|
1456
|
+
reason: sides.jira ? "Jira account is not verified" : "Jira is out of scope"
|
|
1457
|
+
};
|
|
1458
|
+
const ignoredBySession = new Map(sessions.flatMap((session) => {
|
|
1459
|
+
const original = deterministicAttribution(session, { standingMap });
|
|
1460
|
+
return original === null || !ignoredTickets.has(original.ticketKey)
|
|
1461
|
+
? []
|
|
1462
|
+
: [[session.sessionId, original.ticketKey]];
|
|
1463
|
+
}));
|
|
1464
|
+
const { attributions: primaryAttributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions.map((session) => ({
|
|
1465
|
+
...session,
|
|
1466
|
+
candidateKeys: session.candidateKeys.filter((key) => !ignoredTickets.has(key))
|
|
1467
|
+
})), {
|
|
1468
|
+
standingMap,
|
|
1469
|
+
ignoredTickets,
|
|
1385
1470
|
confidenceFloor: cfg.sessionConfidenceFloor,
|
|
1386
1471
|
mode: options?.attribution ?? "full",
|
|
1387
|
-
onProgress
|
|
1472
|
+
onProgress,
|
|
1473
|
+
candidateFact: candidateFact(ranking)
|
|
1474
|
+
});
|
|
1475
|
+
// A split is a guess from mention counts: a deterministic read reports the session unplaced
|
|
1476
|
+
// instead, so an unattended watch never downgrades a branch-placed row to `split`.
|
|
1477
|
+
const attributions = primaryAttributions.map((attribution, index) => {
|
|
1478
|
+
const session = sessions[index];
|
|
1479
|
+
return session === undefined || options?.attribution === "deterministic" ?
|
|
1480
|
+
attribution :
|
|
1481
|
+
splitSessionAttribution(session, attribution, ranking._tag === "Known" && jiraState?.availability === "verified" ? ranking.sprint : undefined, ignoredTickets);
|
|
1388
1482
|
});
|
|
1389
1483
|
// Windows first, then attribution, then sharing — in that order, because time is divided
|
|
1390
1484
|
// between distinct Issue Keys, not between sessions. Two sessions on one ticket must union
|
|
@@ -1397,27 +1491,43 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1397
1491
|
observedAtMs: Math.min(period.to.getTime(), nowMs),
|
|
1398
1492
|
boundsBySession: new Map(sessions.flatMap((session) => session.boundedAtMs === null ? [] : [[session.sessionId, session.boundedAtMs]]))
|
|
1399
1493
|
});
|
|
1400
|
-
const
|
|
1494
|
+
const attributionBySession = new Map(attributions.map((attribution) => [attribution.sessionId, attribution]));
|
|
1495
|
+
const ignored = ignoredCredits(windows.map((window) => ({
|
|
1496
|
+
...window,
|
|
1497
|
+
ticketKey: ignoredBySession.get(window.sessionId) ?? null
|
|
1498
|
+
})), ignoredTickets);
|
|
1499
|
+
const eligibleWindows = windows.filter((window) => {
|
|
1500
|
+
const key = attributionBySession.get(window.sessionId)?.ticketKey;
|
|
1501
|
+
return !ignoredBySession.has(window.sessionId) ||
|
|
1502
|
+
(attributionBySession.get(window.sessionId)?.targets?.length ?? 0) > 0 ||
|
|
1503
|
+
(key !== null && key !== undefined && !ignoredTickets.has(key));
|
|
1504
|
+
});
|
|
1505
|
+
const split = splitCredits(eligibleWindows, attributions, {
|
|
1401
1506
|
cwdBySession: new Map(sessions.map((session) => [session.sessionId, session.cwd])),
|
|
1402
|
-
dwellSeconds: cfg.sessionDwellSeconds
|
|
1507
|
+
dwellSeconds: cfg.sessionDwellSeconds,
|
|
1508
|
+
priority: ticketPriority(ranking),
|
|
1509
|
+
ignoredTickets
|
|
1403
1510
|
});
|
|
1404
|
-
const sides = options?.sides ?? bothSides;
|
|
1405
1511
|
yield* onProgress({ _tag: "ReadingRecordedTime", sides });
|
|
1406
1512
|
return yield* refreshRecordedTime(period, {
|
|
1407
1513
|
attributed: split.attributed,
|
|
1408
|
-
sessionEvidence: windows.
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1514
|
+
sessionEvidence: windows.flatMap((window) => {
|
|
1515
|
+
const attribution = attributionBySession.get(window.sessionId);
|
|
1516
|
+
const keys = attribution?.targets?.map((target) => target.ticketKey) ??
|
|
1517
|
+
[attribution?.ticketKey ?? ignoredBySession.get(window.sessionId) ?? null];
|
|
1518
|
+
return keys.map((ticketKey) => ({ ...window, ticketKey }));
|
|
1519
|
+
}),
|
|
1412
1520
|
sides,
|
|
1413
1521
|
withheld: split.withheld,
|
|
1414
1522
|
unattributed: split.unattributed,
|
|
1523
|
+
ignored,
|
|
1524
|
+
ignoredTickets: [...ignoredTickets].sort(),
|
|
1415
1525
|
attributorAvailable,
|
|
1416
1526
|
sessionCount: sessions.length,
|
|
1417
1527
|
sessionRootCount: cfg.sessionRoots.length,
|
|
1418
1528
|
attributorCalls,
|
|
1419
1529
|
digests: new Map(sessions.map((session) => [session.sessionId, session.digest]))
|
|
1420
|
-
});
|
|
1530
|
+
}, undefined, jiraState, preReadJira);
|
|
1421
1531
|
});
|
|
1422
1532
|
return {
|
|
1423
1533
|
compare,
|