paperclip-github-plugin 0.5.3 → 0.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/dist/worker.js CHANGED
@@ -561,6 +561,12 @@ function parseRepositoryReference(repositoryInput) {
561
561
  }
562
562
  }
563
563
 
564
+ // src/kpi-contract.ts
565
+ var GITHUB_SYNC_PLUGIN_ID = "paperclip-github-plugin";
566
+ var COMPANY_METRIC_WEBHOOK_ENDPOINT_KEY = "record-company-metric-event";
567
+ var COMPANY_METRIC_WEBHOOK_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/webhooks/${COMPANY_METRIC_WEBHOOK_ENDPOINT_KEY}`;
568
+ var COMPANY_METRIC_WEBHOOK_AUTH_HEADER = "authorization";
569
+
564
570
  // src/paperclip-health.ts
565
571
  function normalizeOptionalString(value) {
566
572
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
@@ -604,6 +610,10 @@ var IMPORT_REGISTRY_SCOPE = {
604
610
  scopeKind: "instance",
605
611
  stateKey: "paperclip-github-plugin-import-registry"
606
612
  };
613
+ var COMPANY_KPI_SCOPE = {
614
+ scopeKind: "instance",
615
+ stateKey: "paperclip-github-plugin-company-kpis"
616
+ };
607
617
  var DEFAULT_SCHEDULE_FREQUENCY_MINUTES = 15;
608
618
  var DEFAULT_IMPORTED_ISSUE_STATUS = "backlog";
609
619
  var DEFAULT_IGNORED_GITHUB_ISSUE_USERNAMES = ["renovate"];
@@ -629,6 +639,11 @@ var SYNC_PROGRESS_PERSIST_INTERVAL_MS = 250;
629
639
  var MAX_SYNC_FAILURE_LOG_ENTRIES = 25;
630
640
  var GITHUB_SECONDARY_RATE_LIMIT_FALLBACK_MS = 6e4;
631
641
  var IMPORTED_ISSUE_WAKEUP_CONCURRENCY = 4;
642
+ var COMPANY_KPI_CHART_WINDOW_DAYS = 14;
643
+ var COMPANY_KPI_COMPARISON_WINDOW_DAYS = 30;
644
+ var MAX_COMPANY_BACKLOG_SNAPSHOTS = 180;
645
+ var MAX_COMPANY_ACTIVITY_ROLLUPS = 365;
646
+ var MAX_COMPANY_METRIC_EVENT_KEYS = 2e3;
632
647
  var MISSING_GITHUB_TOKEN_SYNC_MESSAGE = "Configure a GitHub token before running sync.";
633
648
  var MISSING_GITHUB_TOKEN_SYNC_ACTION = 'Open settings and save a GitHub token secret, or create $PAPERCLIP_HOME/plugins/github-sync/config.json (or ~/.paperclip/plugins/github-sync/config.json when PAPERCLIP_HOME is unset) with a "githubToken" value, and then run sync again.';
634
649
  var MISSING_MAPPING_SYNC_MESSAGE = "Save at least one mapping with a created Paperclip project before running sync.";
@@ -646,6 +661,7 @@ var AI_AUTHORED_MARKDOWN_FOOTER_PATTERN = /\n\n---\n###### ✨ This (?:comment|i
646
661
  var HIDDEN_GITHUB_IMPORT_MARKER_PREFIX = "<!-- paperclip-github-plugin-imported-from: ";
647
662
  var HIDDEN_GITHUB_IMPORT_MARKER_SUFFIX = " -->";
648
663
  var EMPTY_GITHUB_ISSUE_DESCRIPTION_PLACEHOLDER = "_No description provided on GitHub._";
664
+ var pluginRuntimeContext = null;
649
665
  function normalizeCompanyId(value) {
650
666
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
651
667
  }
@@ -2528,6 +2544,144 @@ async function buildToolbarSyncState(ctx, input) {
2528
2544
  savedMappingCount
2529
2545
  };
2530
2546
  }
2547
+ function shiftIsoDay(day, offsetDays) {
2548
+ const parsed = /* @__PURE__ */ new Date(`${day}T00:00:00.000Z`);
2549
+ parsed.setUTCDate(parsed.getUTCDate() + offsetDays);
2550
+ return parsed.toISOString().slice(0, 10);
2551
+ }
2552
+ function listIsoDayWindow(endDay, days) {
2553
+ return Array.from(
2554
+ { length: Math.max(0, days) },
2555
+ (_, index) => shiftIsoDay(endDay, index - (days - 1))
2556
+ );
2557
+ }
2558
+ function getBacklogValueOnOrBeforeDay(snapshots, day) {
2559
+ let currentValue;
2560
+ for (const snapshot of snapshots) {
2561
+ if (snapshot.day > day) {
2562
+ break;
2563
+ }
2564
+ currentValue = snapshot.openIssueCount;
2565
+ }
2566
+ return currentValue;
2567
+ }
2568
+ function buildBacklogHistorySeries(snapshots, endDay, days) {
2569
+ const series = [];
2570
+ for (const day of listIsoDayWindow(endDay, days)) {
2571
+ const value = getBacklogValueOnOrBeforeDay(snapshots, day);
2572
+ if (value === void 0) {
2573
+ continue;
2574
+ }
2575
+ series.push({
2576
+ day,
2577
+ value
2578
+ });
2579
+ }
2580
+ return series;
2581
+ }
2582
+ function getCompanyActivityMetricValue(rollup, metric) {
2583
+ return metric === "githubIssuesClosedCount" ? rollup.githubIssuesClosedCount : rollup.paperclipPullRequestsCreatedCount;
2584
+ }
2585
+ function buildActivityHistorySeries(rollups, metric, endDay, days) {
2586
+ const rollupsByDay = new Map(rollups.map((rollup) => [rollup.day, rollup]));
2587
+ return listIsoDayWindow(endDay, days).map((day) => ({
2588
+ day,
2589
+ value: getCompanyActivityMetricValue(
2590
+ rollupsByDay.get(day) ?? createEmptyCompanyActivityRollup(day, `${day}T00:00:00.000Z`),
2591
+ metric
2592
+ )
2593
+ }));
2594
+ }
2595
+ function sumActivityMetricForWindow(rollups, metric, endDay, days) {
2596
+ return buildActivityHistorySeries(rollups, metric, endDay, days).reduce((sum, point) => sum + point.value, 0);
2597
+ }
2598
+ async function buildDashboardMetricsData(ctx, input) {
2599
+ const companyId = normalizeCompanyId(input.companyId);
2600
+ if (!companyId) {
2601
+ return {
2602
+ status: "company_required",
2603
+ historyWindowDays: COMPANY_KPI_CHART_WINDOW_DAYS,
2604
+ comparisonWindowDays: COMPANY_KPI_COMPARISON_WINDOW_DAYS,
2605
+ notes: {
2606
+ backlogHistoryAvailable: false,
2607
+ activityHistoryAvailable: false
2608
+ }
2609
+ };
2610
+ }
2611
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
2612
+ const mappings = getSyncableMappingsForScope(settings.mappings, companyId);
2613
+ const companyKpis = normalizeCompanyKpiState(await ctx.state.get(COMPANY_KPI_SCOPE));
2614
+ const backlogSnapshots = getCompanyBacklogSnapshots(companyKpis, companyId);
2615
+ const activityRollups = getCompanyActivityRollups(companyKpis, companyId);
2616
+ const latestBacklogSnapshot = backlogSnapshots.at(-1);
2617
+ const latestActivityRollup = activityRollups.at(-1);
2618
+ const backlogEndDay = latestBacklogSnapshot?.day;
2619
+ const activityEndDay = latestActivityRollup?.day;
2620
+ return {
2621
+ status: mappings.length > 0 ? "ready" : "no_mappings",
2622
+ companyId,
2623
+ mappedRepositoryCount: mappings.length,
2624
+ historyWindowDays: COMPANY_KPI_CHART_WINDOW_DAYS,
2625
+ comparisonWindowDays: COMPANY_KPI_COMPARISON_WINDOW_DAYS,
2626
+ backlog: {
2627
+ ...latestBacklogSnapshot ? { lastCapturedAt: latestBacklogSnapshot.capturedAt } : {},
2628
+ ...latestBacklogSnapshot ? { currentOpenIssueCount: latestBacklogSnapshot.openIssueCount } : {},
2629
+ ...backlogEndDay ? {
2630
+ comparisonOpenIssueCount: getBacklogValueOnOrBeforeDay(
2631
+ backlogSnapshots,
2632
+ shiftIsoDay(backlogEndDay, -(COMPANY_KPI_COMPARISON_WINDOW_DAYS - 1))
2633
+ )
2634
+ } : {},
2635
+ history: backlogEndDay ? buildBacklogHistorySeries(backlogSnapshots, backlogEndDay, COMPANY_KPI_CHART_WINDOW_DAYS) : []
2636
+ },
2637
+ githubIssuesClosed: {
2638
+ ...latestActivityRollup ? { lastRecordedAt: latestActivityRollup.updatedAt } : {},
2639
+ currentPeriodCount: activityEndDay ? sumActivityMetricForWindow(
2640
+ activityRollups,
2641
+ "githubIssuesClosedCount",
2642
+ activityEndDay,
2643
+ COMPANY_KPI_COMPARISON_WINDOW_DAYS
2644
+ ) : 0,
2645
+ previousPeriodCount: activityEndDay ? sumActivityMetricForWindow(
2646
+ activityRollups,
2647
+ "githubIssuesClosedCount",
2648
+ shiftIsoDay(activityEndDay, -COMPANY_KPI_COMPARISON_WINDOW_DAYS),
2649
+ COMPANY_KPI_COMPARISON_WINDOW_DAYS
2650
+ ) : 0,
2651
+ history: activityEndDay ? buildActivityHistorySeries(
2652
+ activityRollups,
2653
+ "githubIssuesClosedCount",
2654
+ activityEndDay,
2655
+ COMPANY_KPI_CHART_WINDOW_DAYS
2656
+ ) : []
2657
+ },
2658
+ paperclipPullRequestsCreated: {
2659
+ ...latestActivityRollup ? { lastRecordedAt: latestActivityRollup.updatedAt } : {},
2660
+ currentPeriodCount: activityEndDay ? sumActivityMetricForWindow(
2661
+ activityRollups,
2662
+ "paperclipPullRequestsCreatedCount",
2663
+ activityEndDay,
2664
+ COMPANY_KPI_COMPARISON_WINDOW_DAYS
2665
+ ) : 0,
2666
+ previousPeriodCount: activityEndDay ? sumActivityMetricForWindow(
2667
+ activityRollups,
2668
+ "paperclipPullRequestsCreatedCount",
2669
+ shiftIsoDay(activityEndDay, -COMPANY_KPI_COMPARISON_WINDOW_DAYS),
2670
+ COMPANY_KPI_COMPARISON_WINDOW_DAYS
2671
+ ) : 0,
2672
+ history: activityEndDay ? buildActivityHistorySeries(
2673
+ activityRollups,
2674
+ "paperclipPullRequestsCreatedCount",
2675
+ activityEndDay,
2676
+ COMPANY_KPI_CHART_WINDOW_DAYS
2677
+ ) : []
2678
+ },
2679
+ notes: {
2680
+ backlogHistoryAvailable: backlogSnapshots.length > 0,
2681
+ activityHistoryAvailable: activityRollups.length > 0
2682
+ }
2683
+ };
2684
+ }
2531
2685
  async function buildIssueGitHubDetails(ctx, input) {
2532
2686
  const issueId = typeof input.issueId === "string" && input.issueId.trim() ? input.issueId.trim() : void 0;
2533
2687
  const companyId = typeof input.companyId === "string" && input.companyId.trim() ? input.companyId.trim() : void 0;
@@ -3605,6 +3759,193 @@ function normalizeSettings(value) {
3605
3759
  updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : void 0
3606
3760
  };
3607
3761
  }
3762
+ function getIsoDayString(value) {
3763
+ return coerceDate(value).toISOString().slice(0, 10);
3764
+ }
3765
+ function parseDateValue(value) {
3766
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
3767
+ return value;
3768
+ }
3769
+ if (typeof value === "string" || typeof value === "number") {
3770
+ const parsed = new Date(value);
3771
+ if (!Number.isNaN(parsed.getTime())) {
3772
+ return parsed;
3773
+ }
3774
+ }
3775
+ return void 0;
3776
+ }
3777
+ function normalizeIsoDayString(value) {
3778
+ if (typeof value !== "string" || !value.trim()) {
3779
+ return void 0;
3780
+ }
3781
+ return parseDateValue(value.trim())?.toISOString().slice(0, 10);
3782
+ }
3783
+ function normalizeIsoTimestampString(value) {
3784
+ if (typeof value !== "string" || !value.trim()) {
3785
+ return void 0;
3786
+ }
3787
+ return parseDateValue(value.trim())?.toISOString();
3788
+ }
3789
+ function normalizeCompanyBacklogSnapshot(value) {
3790
+ if (!value || typeof value !== "object") {
3791
+ return null;
3792
+ }
3793
+ const record = value;
3794
+ const day = normalizeIsoDayString(record.day);
3795
+ const capturedAt = normalizeIsoTimestampString(record.capturedAt);
3796
+ const openIssueCount = typeof record.openIssueCount === "number" && record.openIssueCount >= 0 ? Math.floor(record.openIssueCount) : NaN;
3797
+ const repositoryCount = typeof record.repositoryCount === "number" && record.repositoryCount >= 0 ? Math.floor(record.repositoryCount) : NaN;
3798
+ if (!day || !capturedAt || Number.isNaN(openIssueCount) || Number.isNaN(repositoryCount)) {
3799
+ return null;
3800
+ }
3801
+ return {
3802
+ day,
3803
+ capturedAt,
3804
+ openIssueCount,
3805
+ repositoryCount
3806
+ };
3807
+ }
3808
+ function normalizeCompanyBacklogSnapshots(value) {
3809
+ if (!Array.isArray(value)) {
3810
+ return [];
3811
+ }
3812
+ const snapshotsByDay = /* @__PURE__ */ new Map();
3813
+ for (const entry of value) {
3814
+ const snapshot = normalizeCompanyBacklogSnapshot(entry);
3815
+ if (!snapshot) {
3816
+ continue;
3817
+ }
3818
+ const existing = snapshotsByDay.get(snapshot.day);
3819
+ if (!existing || existing.capturedAt.localeCompare(snapshot.capturedAt) <= 0) {
3820
+ snapshotsByDay.set(snapshot.day, snapshot);
3821
+ }
3822
+ }
3823
+ return [...snapshotsByDay.values()].sort((left, right) => left.day.localeCompare(right.day)).slice(-MAX_COMPANY_BACKLOG_SNAPSHOTS);
3824
+ }
3825
+ function createEmptyCompanyActivityRollup(day, updatedAt) {
3826
+ return {
3827
+ day,
3828
+ updatedAt,
3829
+ githubIssuesClosedCount: 0,
3830
+ paperclipPullRequestsCreatedCount: 0
3831
+ };
3832
+ }
3833
+ function normalizeCompanyActivityRollup(value) {
3834
+ if (!value || typeof value !== "object") {
3835
+ return null;
3836
+ }
3837
+ const record = value;
3838
+ const day = normalizeIsoDayString(record.day);
3839
+ const updatedAt = normalizeIsoTimestampString(record.updatedAt);
3840
+ const githubIssuesClosedCount = typeof record.githubIssuesClosedCount === "number" && record.githubIssuesClosedCount >= 0 ? Math.floor(record.githubIssuesClosedCount) : 0;
3841
+ const paperclipPullRequestsCreatedCount = typeof record.paperclipPullRequestsCreatedCount === "number" && record.paperclipPullRequestsCreatedCount >= 0 ? Math.floor(record.paperclipPullRequestsCreatedCount) : 0;
3842
+ const paperclipPullRequestsMergedCount = typeof record.paperclipPullRequestsMergedCount === "number" && record.paperclipPullRequestsMergedCount >= 0 ? Math.floor(record.paperclipPullRequestsMergedCount) : void 0;
3843
+ if (!day || !updatedAt) {
3844
+ return null;
3845
+ }
3846
+ return {
3847
+ day,
3848
+ updatedAt,
3849
+ githubIssuesClosedCount,
3850
+ paperclipPullRequestsCreatedCount,
3851
+ ...paperclipPullRequestsMergedCount !== void 0 ? { paperclipPullRequestsMergedCount } : {}
3852
+ };
3853
+ }
3854
+ function normalizeCompanyActivityRollups(value) {
3855
+ if (!Array.isArray(value)) {
3856
+ return [];
3857
+ }
3858
+ const rollupsByDay = /* @__PURE__ */ new Map();
3859
+ for (const entry of value) {
3860
+ const rollup = normalizeCompanyActivityRollup(entry);
3861
+ if (!rollup) {
3862
+ continue;
3863
+ }
3864
+ const existing = rollupsByDay.get(rollup.day);
3865
+ if (!existing || existing.updatedAt.localeCompare(rollup.updatedAt) <= 0) {
3866
+ rollupsByDay.set(rollup.day, rollup);
3867
+ }
3868
+ }
3869
+ return [...rollupsByDay.values()].sort((left, right) => left.day.localeCompare(right.day)).slice(-MAX_COMPANY_ACTIVITY_ROLLUPS);
3870
+ }
3871
+ function normalizeCompanyMetricEventKeyRecord(value) {
3872
+ if (!value || typeof value !== "object") {
3873
+ return null;
3874
+ }
3875
+ const record = value;
3876
+ const key = typeof record.key === "string" && record.key.trim() ? record.key.trim() : "";
3877
+ const recordedAt = typeof record.recordedAt === "string" ? coerceDate(record.recordedAt).toISOString() : void 0;
3878
+ if (!key || !recordedAt) {
3879
+ return null;
3880
+ }
3881
+ return {
3882
+ key,
3883
+ recordedAt
3884
+ };
3885
+ }
3886
+ function normalizeCompanyMetricEventKeyRecords(value) {
3887
+ if (!Array.isArray(value)) {
3888
+ return [];
3889
+ }
3890
+ const entries = value.map((entry) => normalizeCompanyMetricEventKeyRecord(entry)).filter((entry) => entry !== null).sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
3891
+ const keys = /* @__PURE__ */ new Set();
3892
+ const deduped = [];
3893
+ for (const entry of entries) {
3894
+ if (keys.has(entry.key)) {
3895
+ continue;
3896
+ }
3897
+ keys.add(entry.key);
3898
+ deduped.push(entry);
3899
+ }
3900
+ return deduped.slice(-MAX_COMPANY_METRIC_EVENT_KEYS);
3901
+ }
3902
+ function normalizeCompanyBacklogSnapshotsByCompanyId(value) {
3903
+ if (!value || typeof value !== "object") {
3904
+ return void 0;
3905
+ }
3906
+ const entries = Object.entries(value).map(([companyId, snapshots]) => {
3907
+ const normalizedCompanyId = normalizeCompanyId(companyId);
3908
+ const normalizedSnapshots = normalizeCompanyBacklogSnapshots(snapshots);
3909
+ return normalizedCompanyId && normalizedSnapshots.length > 0 ? [normalizedCompanyId, normalizedSnapshots] : null;
3910
+ }).filter((entry) => entry !== null);
3911
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
3912
+ }
3913
+ function normalizeCompanyActivityRollupsByCompanyId(value) {
3914
+ if (!value || typeof value !== "object") {
3915
+ return void 0;
3916
+ }
3917
+ const entries = Object.entries(value).map(([companyId, rollups]) => {
3918
+ const normalizedCompanyId = normalizeCompanyId(companyId);
3919
+ const normalizedRollups = normalizeCompanyActivityRollups(rollups);
3920
+ return normalizedCompanyId && normalizedRollups.length > 0 ? [normalizedCompanyId, normalizedRollups] : null;
3921
+ }).filter((entry) => entry !== null);
3922
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
3923
+ }
3924
+ function normalizeCompanyMetricEventKeysByCompanyId(value) {
3925
+ if (!value || typeof value !== "object") {
3926
+ return void 0;
3927
+ }
3928
+ const entries = Object.entries(value).map(([companyId, records]) => {
3929
+ const normalizedCompanyId = normalizeCompanyId(companyId);
3930
+ const normalizedRecords = normalizeCompanyMetricEventKeyRecords(records);
3931
+ return normalizedCompanyId && normalizedRecords.length > 0 ? [normalizedCompanyId, normalizedRecords] : null;
3932
+ }).filter((entry) => entry !== null);
3933
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
3934
+ }
3935
+ function normalizeCompanyKpiState(value) {
3936
+ if (!value || typeof value !== "object") {
3937
+ return {};
3938
+ }
3939
+ const record = value;
3940
+ const backlogSnapshotsByCompanyId = normalizeCompanyBacklogSnapshotsByCompanyId(record.backlogSnapshotsByCompanyId);
3941
+ const activityRollupsByCompanyId = normalizeCompanyActivityRollupsByCompanyId(record.activityRollupsByCompanyId);
3942
+ const metricEventKeysByCompanyId = normalizeCompanyMetricEventKeysByCompanyId(record.metricEventKeysByCompanyId);
3943
+ return {
3944
+ ...backlogSnapshotsByCompanyId ? { backlogSnapshotsByCompanyId } : {},
3945
+ ...activityRollupsByCompanyId ? { activityRollupsByCompanyId } : {},
3946
+ ...metricEventKeysByCompanyId ? { metricEventKeysByCompanyId } : {}
3947
+ };
3948
+ }
3608
3949
  function getScopedSyncState(settings, companyId) {
3609
3950
  const normalizedCompanyId = normalizeCompanyId(companyId);
3610
3951
  if (!normalizedCompanyId) {
@@ -3702,6 +4043,149 @@ function getSyncableMappingsForScope(mappings, companyId) {
3702
4043
  function hasAnyScopedValue(valueByCompanyId) {
3703
4044
  return Boolean(valueByCompanyId && Object.keys(valueByCompanyId).length > 0);
3704
4045
  }
4046
+ function getCompanyBacklogSnapshots(state, companyId) {
4047
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4048
+ if (!normalizedCompanyId) {
4049
+ return [];
4050
+ }
4051
+ return normalizeCompanyBacklogSnapshots(state.backlogSnapshotsByCompanyId?.[normalizedCompanyId]);
4052
+ }
4053
+ function getCompanyActivityRollups(state, companyId) {
4054
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4055
+ if (!normalizedCompanyId) {
4056
+ return [];
4057
+ }
4058
+ return normalizeCompanyActivityRollups(state.activityRollupsByCompanyId?.[normalizedCompanyId]);
4059
+ }
4060
+ function upsertCompanyBacklogSnapshot(state, companyId, snapshot) {
4061
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4062
+ if (!normalizedCompanyId) {
4063
+ return state;
4064
+ }
4065
+ const nextSnapshots = [
4066
+ ...getCompanyBacklogSnapshots(state, normalizedCompanyId).filter((entry) => entry.day !== snapshot.day),
4067
+ snapshot
4068
+ ].sort((left, right) => left.day.localeCompare(right.day)).slice(-MAX_COMPANY_BACKLOG_SNAPSHOTS);
4069
+ return {
4070
+ ...state,
4071
+ backlogSnapshotsByCompanyId: {
4072
+ ...state.backlogSnapshotsByCompanyId ?? {},
4073
+ [normalizedCompanyId]: nextSnapshots
4074
+ }
4075
+ };
4076
+ }
4077
+ function incrementCompanyActivityRollup(state, params) {
4078
+ const normalizedCompanyId = normalizeCompanyId(params.companyId);
4079
+ if (!normalizedCompanyId) {
4080
+ return state;
4081
+ }
4082
+ const occurredAt = coerceDate(params.occurredAt ?? /* @__PURE__ */ new Date()).toISOString();
4083
+ const day = getIsoDayString(occurredAt);
4084
+ const count = Math.max(1, Math.floor(params.count ?? 1));
4085
+ const nextRollups = [...getCompanyActivityRollups(state, normalizedCompanyId)];
4086
+ const existingIndex = nextRollups.findIndex((entry) => entry.day === day);
4087
+ const existing = existingIndex >= 0 ? nextRollups[existingIndex] : createEmptyCompanyActivityRollup(day, occurredAt);
4088
+ const updated = {
4089
+ ...existing,
4090
+ updatedAt: occurredAt,
4091
+ [params.metric]: existing[params.metric] + count
4092
+ };
4093
+ if (existingIndex >= 0) {
4094
+ nextRollups.splice(existingIndex, 1, updated);
4095
+ } else {
4096
+ nextRollups.push(updated);
4097
+ }
4098
+ nextRollups.sort((left, right) => left.day.localeCompare(right.day));
4099
+ return {
4100
+ ...state,
4101
+ activityRollupsByCompanyId: {
4102
+ ...state.activityRollupsByCompanyId ?? {},
4103
+ [normalizedCompanyId]: nextRollups.slice(-MAX_COMPANY_ACTIVITY_ROLLUPS)
4104
+ }
4105
+ };
4106
+ }
4107
+ function hasRecordedCompanyMetricEventKey(state, companyId, eventKey) {
4108
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4109
+ if (!normalizedCompanyId || !eventKey.trim()) {
4110
+ return false;
4111
+ }
4112
+ return (state.metricEventKeysByCompanyId?.[normalizedCompanyId] ?? []).some((entry) => entry.key === eventKey);
4113
+ }
4114
+ function rememberCompanyMetricEventKey(state, companyId, eventKey, recordedAt) {
4115
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4116
+ const trimmedKey = eventKey.trim();
4117
+ if (!normalizedCompanyId || !trimmedKey) {
4118
+ return state;
4119
+ }
4120
+ const nextRecords = [
4121
+ ...(state.metricEventKeysByCompanyId?.[normalizedCompanyId] ?? []).filter((entry) => entry.key !== trimmedKey),
4122
+ {
4123
+ key: trimmedKey,
4124
+ recordedAt
4125
+ }
4126
+ ].sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)).slice(-MAX_COMPANY_METRIC_EVENT_KEYS);
4127
+ return {
4128
+ ...state,
4129
+ metricEventKeysByCompanyId: {
4130
+ ...state.metricEventKeysByCompanyId ?? {},
4131
+ [normalizedCompanyId]: nextRecords
4132
+ }
4133
+ };
4134
+ }
4135
+ function buildCompanyMetricEventKey(params) {
4136
+ const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(params.pullRequestUrl);
4137
+ if (pullRequestUrl) {
4138
+ return `${params.metric}:${pullRequestUrl}`;
4139
+ }
4140
+ const repository = typeof params.repositoryUrl === "string" ? parseRepositoryReference(params.repositoryUrl) : null;
4141
+ const pullRequestNumber = typeof params.pullRequestNumber === "number" && params.pullRequestNumber >= 1 ? Math.floor(params.pullRequestNumber) : void 0;
4142
+ if (repository && pullRequestNumber) {
4143
+ return `${params.metric}:${repository.url}/pull/${pullRequestNumber}`;
4144
+ }
4145
+ const explicitEventKey = normalizeOptionalString2(params.eventKey);
4146
+ if (explicitEventKey) {
4147
+ return `${params.metric}:${explicitEventKey}`;
4148
+ }
4149
+ return void 0;
4150
+ }
4151
+ function recordCompanyActivityMetricEvent(state, params) {
4152
+ const normalizedCompanyId = normalizeCompanyId(params.companyId);
4153
+ if (!normalizedCompanyId) {
4154
+ return {
4155
+ state,
4156
+ recorded: false
4157
+ };
4158
+ }
4159
+ const occurredAt = coerceDate(params.occurredAt ?? /* @__PURE__ */ new Date()).toISOString();
4160
+ const eventKey = buildCompanyMetricEventKey({
4161
+ metric: params.metric,
4162
+ eventKey: params.eventKey,
4163
+ repositoryUrl: params.repositoryUrl,
4164
+ pullRequestNumber: params.pullRequestNumber,
4165
+ pullRequestUrl: params.pullRequestUrl
4166
+ });
4167
+ if (eventKey && hasRecordedCompanyMetricEventKey(state, normalizedCompanyId, eventKey)) {
4168
+ return {
4169
+ state,
4170
+ recorded: false,
4171
+ eventKey
4172
+ };
4173
+ }
4174
+ let nextState = incrementCompanyActivityRollup(state, {
4175
+ companyId: normalizedCompanyId,
4176
+ metric: params.metric,
4177
+ count: params.count,
4178
+ occurredAt
4179
+ });
4180
+ if (eventKey) {
4181
+ nextState = rememberCompanyMetricEventKey(nextState, normalizedCompanyId, eventKey, occurredAt);
4182
+ }
4183
+ return {
4184
+ state: nextState,
4185
+ recorded: true,
4186
+ ...eventKey ? { eventKey } : {}
4187
+ };
4188
+ }
3705
4189
  function normalizeImportRegistry(value) {
3706
4190
  if (!Array.isArray(value)) {
3707
4191
  return [];
@@ -3722,6 +4206,7 @@ function normalizeImportRegistry(value) {
3722
4206
  const paperclipProjectId = typeof record.paperclipProjectId === "string" && record.paperclipProjectId.trim() ? record.paperclipProjectId.trim() : void 0;
3723
4207
  const companyId = typeof record.companyId === "string" && record.companyId.trim() ? record.companyId.trim() : void 0;
3724
4208
  const lastSeenCommentCount = typeof record.lastSeenCommentCount === "number" && record.lastSeenCommentCount >= 0 ? Math.floor(record.lastSeenCommentCount) : void 0;
4209
+ const lastSeenGitHubState = record.lastSeenGitHubState === "open" || record.lastSeenGitHubState === "closed" ? record.lastSeenGitHubState : void 0;
3725
4210
  const linkedPullRequestCommentCounts = normalizeGitHubPullRequestCommentCountRecords(
3726
4211
  record.linkedPullRequestCommentCounts
3727
4212
  );
@@ -3738,6 +4223,7 @@ function normalizeImportRegistry(value) {
3738
4223
  ...paperclipProjectId ? { paperclipProjectId } : {},
3739
4224
  ...companyId ? { companyId } : {},
3740
4225
  ...lastSeenCommentCount !== void 0 ? { lastSeenCommentCount } : {},
4226
+ ...lastSeenGitHubState ? { lastSeenGitHubState } : {},
3741
4227
  ...linkedPullRequestCommentCounts.length > 0 ? { linkedPullRequestCommentCounts } : {}
3742
4228
  };
3743
4229
  }).filter((entry) => entry !== null);
@@ -3784,6 +4270,7 @@ function buildImportedIssueRecord(mapping, issue, paperclipIssueId, importedAt)
3784
4270
  paperclipIssueId,
3785
4271
  importedAt,
3786
4272
  lastSeenCommentCount: issue.commentsCount,
4273
+ lastSeenGitHubState: issue.state,
3787
4274
  linkedPullRequestCommentCounts: [],
3788
4275
  repositoryUrl: getNormalizedMappingRepositoryUrl(mapping),
3789
4276
  paperclipProjectId: mapping.paperclipProjectId,
@@ -3794,6 +4281,7 @@ function refreshImportedIssueRecordForMapping(record, mapping, issue) {
3794
4281
  record.mappingId = mapping.id;
3795
4282
  record.githubIssueNumber = issue.number;
3796
4283
  record.lastSeenCommentCount ??= issue.commentsCount;
4284
+ record.lastSeenGitHubState ??= issue.state;
3797
4285
  record.repositoryUrl = getNormalizedMappingRepositoryUrl(mapping);
3798
4286
  record.paperclipProjectId = mapping.paperclipProjectId;
3799
4287
  record.companyId = mapping.companyId;
@@ -5495,6 +5983,24 @@ function parseGitHubIssueHtmlUrl(value) {
5495
5983
  function normalizeGitHubIssueHtmlUrl(value) {
5496
5984
  return parseGitHubIssueHtmlUrl(value)?.issueUrl;
5497
5985
  }
5986
+ function normalizeGitHubPullRequestHtmlUrl(value) {
5987
+ if (typeof value !== "string" || !value.trim()) {
5988
+ return void 0;
5989
+ }
5990
+ try {
5991
+ const parsed = new URL(value);
5992
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") {
5993
+ return void 0;
5994
+ }
5995
+ const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i);
5996
+ if (!match) {
5997
+ return void 0;
5998
+ }
5999
+ return `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`;
6000
+ } catch {
6001
+ return void 0;
6002
+ }
6003
+ }
5498
6004
  function escapeRegExp(value) {
5499
6005
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5500
6006
  }
@@ -6137,16 +6643,7 @@ async function upsertStatusTransitionCommentAnnotation(ctx, params) {
6137
6643
  });
6138
6644
  }
6139
6645
  function coerceDate(value) {
6140
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
6141
- return value;
6142
- }
6143
- if (typeof value === "string" || typeof value === "number") {
6144
- const parsed = new Date(value);
6145
- if (!Number.isNaN(parsed.getTime())) {
6146
- return parsed;
6147
- }
6148
- }
6149
- return /* @__PURE__ */ new Date();
6646
+ return parseDateValue(value) ?? /* @__PURE__ */ new Date();
6150
6647
  }
6151
6648
  function parsePaperclipIssueLabel(value, expectedCompanyId) {
6152
6649
  if (!value || typeof value !== "object") {
@@ -6208,6 +6705,9 @@ function getPaperclipIssueEndpoint(baseUrl, issueId) {
6208
6705
  function getPaperclipHealthEndpoint(baseUrl) {
6209
6706
  return new URL("/api/health", baseUrl).toString();
6210
6707
  }
6708
+ function getPaperclipCurrentAgentEndpoint(baseUrl) {
6709
+ return new URL("/api/agents/me", baseUrl).toString();
6710
+ }
6211
6711
  function getPaperclipAgentWakeupEndpoint(baseUrl, agentId) {
6212
6712
  return new URL(`/api/agents/${agentId}/wakeup`, baseUrl).toString();
6213
6713
  }
@@ -7342,7 +7842,7 @@ async function ensurePaperclipIssueImported(ctx, mapping, advancedSettings, issu
7342
7842
  }
7343
7843
  return createdIssue.id;
7344
7844
  }
7345
- async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mapping, advancedSettings, allIssuesById, importedIssues, createdIssueIds, availableLabels, paperclipApiBaseUrl, linkedPullRequestsByIssueNumber, issueStatusSnapshotCache, pullRequestStatusCache, repositoryMaintainerCache, syncFailureContext, failures, assertNotCancelled, onProgress) {
7845
+ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mapping, advancedSettings, allIssuesById, importedIssues, createdIssueIds, availableLabels, paperclipApiBaseUrl, linkedPullRequestsByIssueNumber, issueStatusSnapshotCache, pullRequestStatusCache, repositoryMaintainerCache, syncFailureContext, failures, assertNotCancelled, onGitHubIssueClosed, onProgress) {
7346
7846
  if (!mapping.companyId || !ctx.issues || typeof ctx.issues.get !== "function" || typeof ctx.issues.update !== "function") {
7347
7847
  return {
7348
7848
  updatedStatusesCount: 0,
@@ -7423,6 +7923,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
7423
7923
  pullRequestStatusCache
7424
7924
  );
7425
7925
  const snapshotLinkedPullRequestNumbers = snapshot?.linkedPullRequests.map((pullRequest) => pullRequest.number) ?? [];
7926
+ const previousGitHubState = importedIssue.lastSeenGitHubState;
7426
7927
  if (!doIssueNumberListsMatch(snapshotLinkedPullRequestNumbers, warmedLinkedPullRequestNumbers)) {
7427
7928
  updateSyncFailureContext(syncFailureContext, {
7428
7929
  phase: "syncing_description",
@@ -7462,6 +7963,15 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
7462
7963
  },
7463
7964
  snapshot.linkedPullRequests
7464
7965
  );
7966
+ if (previousGitHubState === "open" && snapshot.state === "closed" && typeof onGitHubIssueClosed === "function") {
7967
+ await onGitHubIssueClosed({
7968
+ companyId: mapping.companyId,
7969
+ githubIssueId: githubIssue.id,
7970
+ githubIssueNumber: githubIssue.number,
7971
+ repositoryUrl: repository.url,
7972
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
7973
+ });
7974
+ }
7465
7975
  const previousCommentCount = importedIssue.lastSeenCommentCount;
7466
7976
  const hasNewIssueComments = snapshot.commentCount > (previousCommentCount ?? snapshot.commentCount);
7467
7977
  const hasTrustedNewIssueComment = paperclipIssue.status === "backlog" || !hasNewIssueComments ? false : await hasTrustedNewGitHubIssueComment({
@@ -7526,6 +8036,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
7526
8036
  const shouldWakeTransitionAssignee = paperclipIssue.status !== nextStatus && nextTransitionAssignee?.principal.kind === "agent" && isActionablePaperclipIssueStatus(nextStatus) && (nextAssigneeChanged || paperclipIssue.status !== nextStatus);
7527
8037
  importedIssue.githubIssueNumber = githubIssue.number;
7528
8038
  importedIssue.lastSeenCommentCount = snapshot.commentCount;
8039
+ importedIssue.lastSeenGitHubState = snapshot.state;
7529
8040
  importedIssue.linkedPullRequestCommentCounts = currentLinkedPullRequestCommentCounts;
7530
8041
  if (paperclipIssue.status === nextStatus) {
7531
8042
  if (shouldClearTransitionAssignee) {
@@ -7827,6 +8338,259 @@ async function executeGitHubTool(fn) {
7827
8338
  return buildToolErrorResult(error);
7828
8339
  }
7829
8340
  }
8341
+ function normalizeCompanyActivityMetricInputValue(value) {
8342
+ switch (value) {
8343
+ case "pull_request_created":
8344
+ return "paperclipPullRequestsCreatedCount";
8345
+ default:
8346
+ return void 0;
8347
+ }
8348
+ }
8349
+ async function persistCompanyActivityMetricEvent(ctx, params, options = {}) {
8350
+ const normalizedCompanyId = normalizeCompanyId(params.companyId);
8351
+ if (!normalizedCompanyId) {
8352
+ return {
8353
+ recorded: false
8354
+ };
8355
+ }
8356
+ const currentState = normalizeCompanyKpiState(await ctx.state.get(COMPANY_KPI_SCOPE));
8357
+ const result = recordCompanyActivityMetricEvent(currentState, {
8358
+ companyId: normalizedCompanyId,
8359
+ metric: params.metric,
8360
+ count: params.count,
8361
+ occurredAt: params.occurredAt,
8362
+ eventKey: params.eventKey,
8363
+ repositoryUrl: params.repositoryUrl,
8364
+ pullRequestNumber: params.pullRequestNumber,
8365
+ pullRequestUrl: params.pullRequestUrl
8366
+ });
8367
+ if (!result.recorded) {
8368
+ return {
8369
+ recorded: false,
8370
+ ...result.eventKey ? { eventKey: result.eventKey } : {}
8371
+ };
8372
+ }
8373
+ try {
8374
+ await ctx.state.set(COMPANY_KPI_SCOPE, result.state);
8375
+ } catch (error) {
8376
+ if (options.throwOnPersistFailure) {
8377
+ throw error;
8378
+ }
8379
+ ctx.logger.warn("GitHub Sync could not persist a company activity metric event.", {
8380
+ companyId: normalizedCompanyId,
8381
+ metric: params.metric,
8382
+ repositoryUrl: params.repositoryUrl,
8383
+ pullRequestNumber: params.pullRequestNumber,
8384
+ error: getErrorMessage(error)
8385
+ });
8386
+ return {
8387
+ recorded: false,
8388
+ ...result.eventKey ? { eventKey: result.eventKey } : {}
8389
+ };
8390
+ }
8391
+ return {
8392
+ recorded: true,
8393
+ ...result.eventKey ? { eventKey: result.eventKey } : {}
8394
+ };
8395
+ }
8396
+ function parseWebhookPayloadRecord(input) {
8397
+ if (input.parsedBody && typeof input.parsedBody === "object" && !Array.isArray(input.parsedBody)) {
8398
+ return input.parsedBody;
8399
+ }
8400
+ const rawBody = input.rawBody.trim();
8401
+ if (!rawBody) {
8402
+ throw new Error("Webhook body must be a JSON object.");
8403
+ }
8404
+ let parsed;
8405
+ try {
8406
+ parsed = JSON.parse(rawBody);
8407
+ } catch {
8408
+ throw new Error("Webhook body must be valid JSON.");
8409
+ }
8410
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
8411
+ throw new Error("Webhook body must be a JSON object.");
8412
+ }
8413
+ return parsed;
8414
+ }
8415
+ async function resolveCompanyIdForCompanyMetricEvent(ctx, params) {
8416
+ const requestedCompanyId = normalizeCompanyId(params.companyId);
8417
+ if (requestedCompanyId) {
8418
+ return requestedCompanyId;
8419
+ }
8420
+ if (!params.repositoryUrl) {
8421
+ return void 0;
8422
+ }
8423
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
8424
+ const matchingCompanyIds = [
8425
+ ...new Set(
8426
+ settings.mappings.filter((mapping) => getNormalizedMappingRepositoryUrl(mapping) === params.repositoryUrl).map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId))
8427
+ )
8428
+ ];
8429
+ return matchingCompanyIds.length === 1 ? matchingCompanyIds[0] : void 0;
8430
+ }
8431
+ function getWebhookHeaderValue(headers, name) {
8432
+ const normalizedName = name.trim().toLowerCase();
8433
+ for (const [headerName, headerValue] of Object.entries(headers)) {
8434
+ if (headerName.trim().toLowerCase() !== normalizedName) {
8435
+ continue;
8436
+ }
8437
+ if (typeof headerValue === "string") {
8438
+ const trimmedValue = headerValue.trim();
8439
+ return trimmedValue || void 0;
8440
+ }
8441
+ if (!Array.isArray(headerValue)) {
8442
+ continue;
8443
+ }
8444
+ for (const entry of headerValue) {
8445
+ if (typeof entry !== "string") {
8446
+ continue;
8447
+ }
8448
+ const trimmedValue = entry.trim();
8449
+ if (trimmedValue) {
8450
+ return trimmedValue;
8451
+ }
8452
+ }
8453
+ }
8454
+ return void 0;
8455
+ }
8456
+ function normalizeCompanyMetricWebhookBearerToken(value) {
8457
+ if (!value) {
8458
+ return void 0;
8459
+ }
8460
+ const trimmedValue = value.trim();
8461
+ if (!trimmedValue) {
8462
+ return void 0;
8463
+ }
8464
+ const bearerMatch = trimmedValue.match(/^Bearer\s+(.+)$/i);
8465
+ if (!bearerMatch) {
8466
+ return void 0;
8467
+ }
8468
+ const token = bearerMatch[1]?.trim();
8469
+ return token || void 0;
8470
+ }
8471
+ function normalizePaperclipCurrentAgentRecord(value) {
8472
+ if (!value || typeof value !== "object") {
8473
+ return null;
8474
+ }
8475
+ const record = value;
8476
+ const id = normalizeOptionalString2(record.id);
8477
+ const companyId = normalizeCompanyId(record.companyId);
8478
+ return id && companyId ? {
8479
+ id,
8480
+ companyId
8481
+ } : null;
8482
+ }
8483
+ async function readCompanyMetricWebhookCurrentAgent(paperclipApiBaseUrl, bearerToken) {
8484
+ const response = await fetchPaperclipApi(getPaperclipCurrentAgentEndpoint(paperclipApiBaseUrl), {
8485
+ method: "GET",
8486
+ headers: {
8487
+ accept: "application/json",
8488
+ authorization: `Bearer ${bearerToken}`
8489
+ }
8490
+ });
8491
+ const payloadResult = await readPaperclipApiJsonResponse(response, {
8492
+ operationLabel: "current agent"
8493
+ });
8494
+ if (payloadResult.failure) {
8495
+ if (payloadResult.failure.requiresAuthentication) {
8496
+ throw new Error("Company KPI webhook Authorization must be a valid PAPERCLIP_API_KEY bearer token.");
8497
+ }
8498
+ const detail = payloadResult.failure.errorMessage ? ` ${payloadResult.failure.errorMessage}` : "";
8499
+ throw new Error(`Could not validate the KPI webhook Paperclip API key.${detail}`);
8500
+ }
8501
+ const agent = normalizePaperclipCurrentAgentRecord(payloadResult.data);
8502
+ if (!agent) {
8503
+ throw new Error("Paperclip did not return a usable current agent record while validating the KPI webhook caller.");
8504
+ }
8505
+ return agent;
8506
+ }
8507
+ async function assertCompanyMetricWebhookAuthenticated(ctx, input, companyId) {
8508
+ const rawAuthorization = getWebhookHeaderValue(input.headers, COMPANY_METRIC_WEBHOOK_AUTH_HEADER);
8509
+ const bearerToken = normalizeCompanyMetricWebhookBearerToken(rawAuthorization);
8510
+ if (!bearerToken) {
8511
+ throw new Error(
8512
+ `Missing or invalid ${COMPANY_METRIC_WEBHOOK_AUTH_HEADER} header. Use Bearer <PAPERCLIP_API_KEY>.`
8513
+ );
8514
+ }
8515
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
8516
+ const config = await getResolvedConfig(ctx);
8517
+ const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(settings, config, companyId);
8518
+ if (!paperclipApiBaseUrl) {
8519
+ throw new Error(
8520
+ "A trusted Paperclip API origin is required to validate PAPERCLIP_API_KEY. Set PAPERCLIP_API_URL or save the Paperclip host origin before sending KPI webhook events."
8521
+ );
8522
+ }
8523
+ const currentAgent = await readCompanyMetricWebhookCurrentAgent(paperclipApiBaseUrl, bearerToken);
8524
+ if (normalizeCompanyId(currentAgent.companyId) !== companyId) {
8525
+ throw new Error("Company KPI webhook Paperclip API key belongs to a different company.");
8526
+ }
8527
+ }
8528
+ async function handleCompanyMetricWebhook(ctx, input) {
8529
+ if (input.endpointKey !== COMPANY_METRIC_WEBHOOK_ENDPOINT_KEY) {
8530
+ throw new Error(`Unsupported webhook endpoint: ${input.endpointKey}.`);
8531
+ }
8532
+ const payload = parseWebhookPayloadRecord(input);
8533
+ const repositoryInput = normalizeOptionalString2(payload.repository);
8534
+ const repository = repositoryInput ? parseRepositoryReference(repositoryInput) : null;
8535
+ if (repositoryInput && !repository) {
8536
+ throw new Error("repository must be owner/repo or https://github.com/owner/repo.");
8537
+ }
8538
+ const companyId = await resolveCompanyIdForCompanyMetricEvent(ctx, {
8539
+ companyId: payload.companyId,
8540
+ repositoryUrl: repository?.url
8541
+ });
8542
+ if (!companyId) {
8543
+ throw new Error("companyId is required unless repository maps to exactly one company.");
8544
+ }
8545
+ await assertCompanyMetricWebhookAuthenticated(ctx, input, companyId);
8546
+ const metric = normalizeCompanyActivityMetricInputValue(payload.metric);
8547
+ if (!metric) {
8548
+ throw new Error('metric must be "pull_request_created".');
8549
+ }
8550
+ const pullRequestNumber = normalizeToolPositiveInteger(payload.pullRequestNumber);
8551
+ const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(payload.pullRequestUrl));
8552
+ const eventKey = normalizeOptionalString2(payload.eventKey);
8553
+ const dedupeKey = buildCompanyMetricEventKey({
8554
+ metric,
8555
+ eventKey,
8556
+ repositoryUrl: repository?.url,
8557
+ pullRequestNumber,
8558
+ pullRequestUrl
8559
+ });
8560
+ if (!dedupeKey) {
8561
+ throw new Error(
8562
+ "Company KPI webhook requires pullRequestUrl, repository plus pullRequestNumber, or eventKey so duplicate deliveries can be ignored."
8563
+ );
8564
+ }
8565
+ const recordedMetric = await persistCompanyActivityMetricEvent(
8566
+ ctx,
8567
+ {
8568
+ companyId,
8569
+ metric,
8570
+ count: normalizeToolPositiveInteger(payload.count),
8571
+ occurredAt: normalizeOptionalString2(payload.occurredAt),
8572
+ eventKey,
8573
+ repositoryUrl: repository?.url,
8574
+ pullRequestNumber,
8575
+ pullRequestUrl
8576
+ },
8577
+ {
8578
+ throwOnPersistFailure: true
8579
+ }
8580
+ );
8581
+ ctx.logger.info(
8582
+ recordedMetric.recorded ? "GitHub Sync recorded a company KPI webhook event." : "GitHub Sync ignored a duplicate company KPI webhook event.",
8583
+ {
8584
+ endpointKey: input.endpointKey,
8585
+ companyId,
8586
+ metric,
8587
+ repositoryUrl: repository?.url,
8588
+ pullRequestNumber,
8589
+ pullRequestUrl,
8590
+ requestId: input.requestId
8591
+ }
8592
+ );
8593
+ }
7830
8594
  async function createGitHubToolOctokit(ctx, companyId) {
7831
8595
  const token = (await resolveGithubToken(ctx, { companyId })).trim();
7832
8596
  if (!token) {
@@ -10782,6 +11546,8 @@ async function performSync(ctx, trigger, options = {}) {
10782
11546
  const config = await getResolvedConfig(ctx);
10783
11547
  const settings = materializeScopedSettings(baseSettings, config, targetCompanyId);
10784
11548
  const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
11549
+ let companyKpiState = normalizeCompanyKpiState(await ctx.state.get(COMPANY_KPI_SCOPE));
11550
+ let companyKpiStateDirty = false;
10785
11551
  const token = typeof options.resolvedToken === "string" ? options.resolvedToken : await resolveGithubToken(ctx, { companyId: targetCompanyId });
10786
11552
  const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(baseSettings, config, targetCompanyId);
10787
11553
  const mappings = getSyncableMappingsForTarget(settings.mappings, options.target);
@@ -10866,6 +11632,19 @@ async function performSync(ctx, trigger, options = {}) {
10866
11632
  erroredIssuesCount: recoverableFailures.length,
10867
11633
  progress: currentProgress
10868
11634
  });
11635
+ async function flushCompanyKpiState() {
11636
+ if (!companyKpiStateDirty) {
11637
+ return;
11638
+ }
11639
+ try {
11640
+ await ctx.state.set(COMPANY_KPI_SCOPE, companyKpiState);
11641
+ companyKpiStateDirty = false;
11642
+ } catch (error) {
11643
+ ctx.logger.warn("GitHub Sync could not persist company KPI state.", {
11644
+ error: getErrorMessage(error)
11645
+ });
11646
+ }
11647
+ }
10869
11648
  async function throwIfSyncCancelled() {
10870
11649
  const cancellationRequest = await getSyncCancellationRequest(ctx);
10871
11650
  if (!cancellationRequest) {
@@ -10918,6 +11697,48 @@ async function performSync(ctx, trigger, options = {}) {
10918
11697
  completedTrackedIssueKeys.add(key);
10919
11698
  completedTrackedIssueCount += 1;
10920
11699
  }
11700
+ function recordCompanyBacklogSnapshotsFromPlans(repositoryPlans2) {
11701
+ if (options.target?.kind === "project" || options.target?.kind === "issue") {
11702
+ return;
11703
+ }
11704
+ const expectedMappingsByCompanyId = /* @__PURE__ */ new Map();
11705
+ for (const mapping of mappings) {
11706
+ const companyId = normalizeCompanyId(mapping.companyId);
11707
+ if (!companyId) {
11708
+ continue;
11709
+ }
11710
+ expectedMappingsByCompanyId.set(companyId, (expectedMappingsByCompanyId.get(companyId) ?? 0) + 1);
11711
+ }
11712
+ const planBacklogByCompanyId = /* @__PURE__ */ new Map();
11713
+ for (const plan of repositoryPlans2) {
11714
+ const companyId = normalizeCompanyId(plan.mapping.companyId);
11715
+ if (!companyId) {
11716
+ continue;
11717
+ }
11718
+ const current = planBacklogByCompanyId.get(companyId) ?? {
11719
+ repositoryCount: 0,
11720
+ openIssueCount: 0
11721
+ };
11722
+ current.repositoryCount += 1;
11723
+ current.openIssueCount += plan.issues.length;
11724
+ planBacklogByCompanyId.set(companyId, current);
11725
+ }
11726
+ const capturedAt = (/* @__PURE__ */ new Date()).toISOString();
11727
+ const day = getIsoDayString(capturedAt);
11728
+ for (const [companyId, expectedRepositoryCount] of expectedMappingsByCompanyId.entries()) {
11729
+ const planned = planBacklogByCompanyId.get(companyId);
11730
+ if (!planned || planned.repositoryCount !== expectedRepositoryCount) {
11731
+ continue;
11732
+ }
11733
+ companyKpiState = upsertCompanyBacklogSnapshot(companyKpiState, companyId, {
11734
+ day,
11735
+ capturedAt,
11736
+ openIssueCount: planned.openIssueCount,
11737
+ repositoryCount: planned.repositoryCount
11738
+ });
11739
+ companyKpiStateDirty = true;
11740
+ }
11741
+ }
10921
11742
  const repositoryPlans = [];
10922
11743
  try {
10923
11744
  await throwIfSyncCancelled();
@@ -11017,6 +11838,7 @@ async function performSync(ctx, trigger, options = {}) {
11017
11838
  continue;
11018
11839
  }
11019
11840
  }
11841
+ recordCompanyBacklogSnapshotsFromPlans(repositoryPlans);
11020
11842
  if (repositoryPlans.length > 0) {
11021
11843
  const firstPlan = repositoryPlans[0];
11022
11844
  currentProgress = {
@@ -11203,6 +12025,17 @@ async function performSync(ctx, trigger, options = {}) {
11203
12025
  failureContext,
11204
12026
  recoverableFailures,
11205
12027
  throwIfSyncCancelled,
12028
+ async (params) => {
12029
+ const recorded = recordCompanyActivityMetricEvent(companyKpiState, {
12030
+ companyId: params.companyId,
12031
+ metric: "githubIssuesClosedCount",
12032
+ eventKey: `${params.repositoryUrl}/issues/${params.githubIssueNumber}:${coerceDate(params.occurredAt).toISOString()}`,
12033
+ repositoryUrl: params.repositoryUrl,
12034
+ occurredAt: params.occurredAt
12035
+ });
12036
+ companyKpiState = recorded.state;
12037
+ companyKpiStateDirty = companyKpiStateDirty || recorded.recorded;
12038
+ },
11206
12039
  async (progress) => {
11207
12040
  markTrackedIssueProcessed(mapping, progress.githubIssueId);
11208
12041
  currentProgress = {
@@ -11306,6 +12139,8 @@ async function performSync(ctx, trigger, options = {}) {
11306
12139
  await saveSettingsSyncState(ctx, currentSettings, next.syncState, targetCompanyId);
11307
12140
  await ctx.state.set(IMPORT_REGISTRY_SCOPE, nextRegistry);
11308
12141
  return materializeScopedSettings(next, config, targetCompanyId);
12142
+ } finally {
12143
+ await flushCompanyKpiState();
11309
12144
  }
11310
12145
  }
11311
12146
  async function startSync(ctx, trigger, options = {}) {
@@ -11687,6 +12522,19 @@ function registerGitHubAgentTools(ctx) {
11687
12522
  "X-GitHub-Api-Version": GITHUB_API_VERSION
11688
12523
  }
11689
12524
  });
12525
+ await persistCompanyActivityMetricEvent(
12526
+ ctx,
12527
+ {
12528
+ companyId: runCtx.companyId,
12529
+ metric: "paperclipPullRequestsCreatedCount",
12530
+ repositoryUrl: repository.url,
12531
+ pullRequestNumber: response.data.number,
12532
+ pullRequestUrl: response.data.html_url ?? void 0
12533
+ },
12534
+ {
12535
+ throwOnPersistFailure: false
12536
+ }
12537
+ );
11690
12538
  return buildToolSuccessResult(
11691
12539
  `Created pull request #${response.data.number} in ${formatRepositoryLabel(repository)}.`,
11692
12540
  {
@@ -12184,6 +13032,7 @@ function shouldStartWorkerHost(moduleUrl, entry = process.argv[1]) {
12184
13032
  }
12185
13033
  var plugin = definePlugin({
12186
13034
  async setup(ctx) {
13035
+ pluginRuntimeContext = ctx;
12187
13036
  ctx.data.register("settings.registration", async (input) => {
12188
13037
  const record = input && typeof input === "object" ? input : {};
12189
13038
  const requestedCompanyId = normalizeCompanyId(record.companyId);
@@ -12218,6 +13067,10 @@ var plugin = definePlugin({
12218
13067
  paperclipBoardAccessNeedsConfigSync: Boolean(savedBoardTokenRef && !configuredBoardTokenRef)
12219
13068
  };
12220
13069
  });
13070
+ ctx.data.register("dashboard.metrics", async (input) => {
13071
+ const record = input && typeof input === "object" ? input : {};
13072
+ return buildDashboardMetricsData(ctx, record);
13073
+ });
12221
13074
  ctx.data.register("sync.toolbarState", async (input) => {
12222
13075
  const record = input && typeof input === "object" ? input : {};
12223
13076
  return buildToolbarSyncState(ctx, record);
@@ -12543,6 +13396,15 @@ var plugin = definePlugin({
12543
13396
  await startSync(ctx, trigger, target ? { target } : {});
12544
13397
  }
12545
13398
  });
13399
+ },
13400
+ async onWebhook(input) {
13401
+ if (!pluginRuntimeContext) {
13402
+ throw new Error("GitHub Sync worker is not ready to handle webhooks yet.");
13403
+ }
13404
+ await handleCompanyMetricWebhook(pluginRuntimeContext, input);
13405
+ },
13406
+ async onShutdown() {
13407
+ pluginRuntimeContext = null;
12546
13408
  }
12547
13409
  });
12548
13410
  var worker_default = plugin;