@comergehq/studio 0.1.27 → 0.1.30

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.
@@ -0,0 +1,279 @@
1
+ import { flushStudioAnalytics, trackStudioEvent } from './client';
2
+ import {
3
+ STUDIO_ANALYTICS_EVENT_VERSION,
4
+ type InteractionSource,
5
+ type StudioAnalyticsEventPayload,
6
+ } from './events';
7
+
8
+ function baseProps() {
9
+ return { event_version: STUDIO_ANALYTICS_EVENT_VERSION } as const;
10
+ }
11
+
12
+ function normalizeError(error: unknown): { error_code?: string; error_domain?: string } {
13
+ if (!error) return {};
14
+
15
+ if (typeof error === 'string') {
16
+ return { error_code: error.slice(0, 120), error_domain: 'string' };
17
+ }
18
+
19
+ if (error instanceof Error) {
20
+ return {
21
+ error_code: error.message.slice(0, 120),
22
+ error_domain: error.name || 'Error',
23
+ };
24
+ }
25
+
26
+ if (typeof error === 'object') {
27
+ const candidate = error as { code?: string | number; name?: string; message?: string };
28
+ return {
29
+ error_code: String(candidate.code ?? candidate.message ?? 'unknown_error').slice(0, 120),
30
+ error_domain: candidate.name ?? 'object',
31
+ };
32
+ }
33
+
34
+ return { error_code: 'unknown_error', error_domain: typeof error };
35
+ }
36
+
37
+ async function trackMutationEvent<TName extends keyof Omit<{
38
+ remix_app: true;
39
+ edit_app: true;
40
+ share_app: true;
41
+ open_merge_request: true;
42
+ approve_merge_request: true;
43
+ reject_merge_request: true;
44
+ test_bundle: true;
45
+ like_app: true;
46
+ unlike_app: true;
47
+ submit_comment: true;
48
+ related_app_switched: true;
49
+ related_app_switch_failed: true;
50
+ }, never>>(
51
+ name: TName,
52
+ payload: StudioAnalyticsEventPayload<TName>
53
+ ) {
54
+ await trackStudioEvent(name, payload);
55
+ await flushStudioAnalytics();
56
+ }
57
+
58
+ let lastOpenCommentsKey: string | null = null;
59
+ let lastOpenCommentsAt = 0;
60
+
61
+ export async function trackRemixApp(params: {
62
+ appId: string;
63
+ sourceAppId: string;
64
+ threadId?: string;
65
+ success: boolean;
66
+ error?: unknown;
67
+ }) {
68
+ const errorProps = params.success ? {} : normalizeError(params.error);
69
+ await trackMutationEvent('remix_app', {
70
+ app_id: params.appId,
71
+ source_app_id: params.sourceAppId,
72
+ thread_id: params.threadId,
73
+ success: params.success,
74
+ ...errorProps,
75
+ ...baseProps(),
76
+ });
77
+ }
78
+
79
+ export async function trackEditApp(params: {
80
+ appId: string;
81
+ threadId: string;
82
+ promptLength: number;
83
+ success: boolean;
84
+ error?: unknown;
85
+ }) {
86
+ const errorProps = params.success ? {} : normalizeError(params.error);
87
+ await trackMutationEvent('edit_app', {
88
+ app_id: params.appId,
89
+ thread_id: params.threadId,
90
+ prompt_length: params.promptLength,
91
+ success: params.success,
92
+ ...errorProps,
93
+ ...baseProps(),
94
+ });
95
+ }
96
+
97
+ export async function trackShareApp(params: {
98
+ appId: string;
99
+ success: boolean;
100
+ error?: unknown;
101
+ }) {
102
+ const errorProps = params.success ? {} : normalizeError(params.error);
103
+ await trackMutationEvent('share_app', {
104
+ app_id: params.appId,
105
+ success: params.success,
106
+ ...errorProps,
107
+ ...baseProps(),
108
+ });
109
+ }
110
+
111
+ export async function trackOpenMergeRequest(params: {
112
+ appId: string;
113
+ mergeRequestId?: string;
114
+ success: boolean;
115
+ error?: unknown;
116
+ }) {
117
+ const errorProps = params.success ? {} : normalizeError(params.error);
118
+ await trackMutationEvent('open_merge_request', {
119
+ app_id: params.appId,
120
+ merge_request_id: params.mergeRequestId,
121
+ success: params.success,
122
+ ...errorProps,
123
+ ...baseProps(),
124
+ });
125
+ }
126
+
127
+ export async function trackApproveMergeRequest(params: {
128
+ appId: string;
129
+ mergeRequestId: string;
130
+ success: boolean;
131
+ error?: unknown;
132
+ }) {
133
+ const errorProps = params.success ? {} : normalizeError(params.error);
134
+ await trackMutationEvent('approve_merge_request', {
135
+ app_id: params.appId,
136
+ merge_request_id: params.mergeRequestId,
137
+ success: params.success,
138
+ ...errorProps,
139
+ ...baseProps(),
140
+ });
141
+ }
142
+
143
+ export async function trackRejectMergeRequest(params: {
144
+ appId: string;
145
+ mergeRequestId: string;
146
+ success: boolean;
147
+ error?: unknown;
148
+ }) {
149
+ const errorProps = params.success ? {} : normalizeError(params.error);
150
+ await trackMutationEvent('reject_merge_request', {
151
+ app_id: params.appId,
152
+ merge_request_id: params.mergeRequestId,
153
+ success: params.success,
154
+ ...errorProps,
155
+ ...baseProps(),
156
+ });
157
+ }
158
+
159
+ export async function trackTestBundle(params: {
160
+ appId: string;
161
+ commitId?: string;
162
+ success: boolean;
163
+ error?: unknown;
164
+ }) {
165
+ const errorProps = params.success ? {} : normalizeError(params.error);
166
+ await trackMutationEvent('test_bundle', {
167
+ app_id: params.appId,
168
+ commit_id: params.commitId,
169
+ success: params.success,
170
+ ...errorProps,
171
+ ...baseProps(),
172
+ });
173
+ }
174
+
175
+ export async function trackLikeApp(params: {
176
+ appId: string;
177
+ source?: InteractionSource;
178
+ success: boolean;
179
+ error?: unknown;
180
+ }) {
181
+ const errorProps = params.success ? {} : normalizeError(params.error);
182
+ await trackMutationEvent('like_app', {
183
+ app_id: params.appId,
184
+ source: params.source ?? 'unknown',
185
+ success: params.success,
186
+ ...errorProps,
187
+ ...baseProps(),
188
+ });
189
+ }
190
+
191
+ export async function trackUnlikeApp(params: {
192
+ appId: string;
193
+ source?: InteractionSource;
194
+ success: boolean;
195
+ error?: unknown;
196
+ }) {
197
+ const errorProps = params.success ? {} : normalizeError(params.error);
198
+ await trackMutationEvent('unlike_app', {
199
+ app_id: params.appId,
200
+ source: params.source ?? 'unknown',
201
+ success: params.success,
202
+ ...errorProps,
203
+ ...baseProps(),
204
+ });
205
+ }
206
+
207
+ export async function trackOpenComments(params: {
208
+ appId: string;
209
+ source?: InteractionSource;
210
+ }) {
211
+ const key = `${params.appId}:${params.source ?? 'unknown'}`;
212
+ const now = Date.now();
213
+ if (lastOpenCommentsKey === key && now - lastOpenCommentsAt < 1000) return;
214
+ lastOpenCommentsKey = key;
215
+ lastOpenCommentsAt = now;
216
+
217
+ await trackStudioEvent('open_comments', {
218
+ app_id: params.appId,
219
+ source: params.source ?? 'unknown',
220
+ ...baseProps(),
221
+ });
222
+ }
223
+
224
+ export async function trackSubmitComment(params: {
225
+ appId: string;
226
+ commentLength: number;
227
+ success: boolean;
228
+ error?: unknown;
229
+ }) {
230
+ const errorProps = params.success ? {} : normalizeError(params.error);
231
+ await trackMutationEvent('submit_comment', {
232
+ app_id: params.appId,
233
+ comment_type: 'general',
234
+ comment_length: params.commentLength,
235
+ success: params.success,
236
+ ...errorProps,
237
+ ...baseProps(),
238
+ });
239
+ }
240
+
241
+ export async function trackRelatedAppsOpened(params: {
242
+ appId: string;
243
+ relatedCount: number;
244
+ }) {
245
+ await trackStudioEvent('related_apps_opened', {
246
+ app_id: params.appId,
247
+ related_count: params.relatedCount,
248
+ ...baseProps(),
249
+ });
250
+ }
251
+
252
+ export async function trackRelatedAppSwitched(params: {
253
+ fromAppId: string;
254
+ toAppId: string;
255
+ targetType: 'original' | 'remix';
256
+ }) {
257
+ await trackMutationEvent('related_app_switched', {
258
+ from_app_id: params.fromAppId,
259
+ to_app_id: params.toAppId,
260
+ target_type: params.targetType,
261
+ ...baseProps(),
262
+ });
263
+ }
264
+
265
+ export async function trackRelatedAppSwitchFailed(params: {
266
+ fromAppId: string;
267
+ toAppId: string;
268
+ reason: string;
269
+ error?: unknown;
270
+ }) {
271
+ const errorProps = normalizeError(params.error);
272
+ await trackMutationEvent('related_app_switch_failed', {
273
+ from_app_id: params.fromAppId,
274
+ to_app_id: params.toAppId,
275
+ reason: params.reason,
276
+ ...errorProps,
277
+ ...baseProps(),
278
+ });
279
+ }
@@ -16,8 +16,14 @@ export type StudioBootstrapProps = UseStudioBootstrapOptions & {
16
16
  renderError?: (error: Error) => React.ReactNode;
17
17
  };
18
18
 
19
- export function StudioBootstrap({ children, fallback, renderError, clientKey }: StudioBootstrapProps) {
20
- const { ready, error, userId } = useStudioBootstrap({ clientKey });
19
+ export function StudioBootstrap({
20
+ children,
21
+ fallback,
22
+ renderError,
23
+ clientKey,
24
+ analyticsEnabled,
25
+ }: StudioBootstrapProps) {
26
+ const { ready, error, userId } = useStudioBootstrap({ clientKey, analyticsEnabled });
21
27
 
22
28
  if (error) {
23
29
  return (
@@ -3,11 +3,13 @@ import * as React from 'react';
3
3
  import { setClientKey } from '../../core/services/http/public';
4
4
  import { ensureAuthenticatedSession, ensureAnonymousSession } from '../../core/services/supabase/auth';
5
5
  import { isSupabaseClientInjected, setSupabaseConfig } from '../../core/services/supabase/client';
6
+ import { identifyStudioUser, initStudioAnalytics, resetStudioAnalytics } from '../analytics/client';
6
7
  const SUPABASE_URL = 'https://xtfxwbckjpfmqubnsusu.supabase.co';
7
8
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s';
8
9
 
9
10
  export type UseStudioBootstrapOptions = {
10
11
  clientKey: string;
12
+ analyticsEnabled?: boolean;
11
13
  };
12
14
 
13
15
  export type StudioBootstrapState = {
@@ -29,11 +31,25 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
29
31
  (async () => {
30
32
  try {
31
33
  setClientKey(options.clientKey);
32
- const requireAuth = isSupabaseClientInjected();
34
+ const hasInjectedSupabase = isSupabaseClientInjected();
35
+ const requireAuth = hasInjectedSupabase;
36
+ const analyticsEnabled = options.analyticsEnabled ?? hasInjectedSupabase;
37
+
38
+ await initStudioAnalytics({
39
+ enabled: analyticsEnabled,
40
+ token: process.env.EXPO_PUBLIC_MIXPANEL_TOKEN,
41
+ serverUrl: process.env.EXPO_PUBLIC_MIXPANEL_SERVER_URL,
42
+ debug: __DEV__,
43
+ });
44
+
33
45
  if (!requireAuth) {
34
46
  setSupabaseConfig({ url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
47
+ await resetStudioAnalytics();
35
48
  }
36
49
  const { user } = requireAuth ? await ensureAuthenticatedSession() : await ensureAnonymousSession();
50
+ if (requireAuth) {
51
+ await identifyStudioUser(user.id);
52
+ }
37
53
 
38
54
  if (cancelled) return;
39
55
  setState({ ready: true, userId: user.id, error: null });
@@ -47,7 +63,7 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
47
63
  return () => {
48
64
  cancelled = true;
49
65
  };
50
- }, [options.clientKey]);
66
+ }, [options.analyticsEnabled, options.clientKey]);
51
67
 
52
68
  return state;
53
69
  }
@@ -3,6 +3,8 @@ import * as Haptics from 'expo-haptics';
3
3
 
4
4
  import type { App } from '../../data/apps/types';
5
5
  import { appLikesRepository } from '../../data/likes/repository';
6
+ import type { InteractionSource } from '../analytics/events';
7
+ import { trackLikeApp, trackOpenComments, trackUnlikeApp } from '../analytics/track';
6
8
 
7
9
  export type UseAppStatsParams = {
8
10
  appId: string;
@@ -11,6 +13,7 @@ export type UseAppStatsParams = {
11
13
  initialForks?: number;
12
14
  initialIsLiked?: boolean;
13
15
  onOpenComments?: () => void;
16
+ interactionSource?: InteractionSource;
14
17
  };
15
18
 
16
19
  export type AppStatsResult = {
@@ -30,6 +33,7 @@ export function useAppStats({
30
33
  initialForks = 0,
31
34
  initialIsLiked = false,
32
35
  onOpenComments,
36
+ interactionSource = 'unknown',
33
37
  }: UseAppStatsParams): AppStatsResult {
34
38
  const [likeCount, setLikeCount] = React.useState(initialLikes);
35
39
  const [commentCount, setCommentCount] = React.useState(initialComments);
@@ -77,15 +81,22 @@ export function useAppStats({
77
81
  if (newIsLiked) {
78
82
  const res = await appLikesRepository.create(appId, {});
79
83
  if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
84
+ await trackLikeApp({ appId, source: interactionSource, success: true });
80
85
  } else {
81
86
  const res = await appLikesRepository.removeMine(appId);
82
87
  if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
88
+ await trackUnlikeApp({ appId, source: interactionSource, success: true });
83
89
  }
84
90
  } catch (e) {
85
91
  setIsLiked(!newIsLiked);
86
92
  setLikeCount((prev) => Math.max(0, prev + (newIsLiked ? -1 : 1)));
93
+ if (newIsLiked) {
94
+ await trackLikeApp({ appId, source: interactionSource, success: false, error: e });
95
+ } else {
96
+ await trackUnlikeApp({ appId, source: interactionSource, success: false, error: e });
97
+ }
87
98
  }
88
- }, [appId, isLiked, likeCount]);
99
+ }, [appId, interactionSource, isLiked, likeCount]);
89
100
 
90
101
  const handleOpenComments = React.useCallback(() => {
91
102
  if (!appId) return;
@@ -93,8 +104,9 @@ export function useAppStats({
93
104
  void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
94
105
  } catch {
95
106
  }
107
+ void trackOpenComments({ appId, source: interactionSource });
96
108
  onOpenComments?.();
97
- }, [appId, onOpenComments]);
109
+ }, [appId, interactionSource, onOpenComments]);
98
110
 
99
111
  return { likeCount, commentCount, forkCount, isLiked, setCommentCount, handleLike, handleOpenComments };
100
112
  }
@@ -5,6 +5,7 @@ import { unzip } from 'react-native-zip-archive';
5
5
 
6
6
  import type { Platform as BundlePlatform, Bundle, BundleAsset, BundleStatus } from '../../data/apps/bundles/types';
7
7
  import { bundlesRepository } from '../../data/apps/bundles/repository';
8
+ import { trackTestBundle } from '../analytics/track';
8
9
 
9
10
  function sleep(ms: number): Promise<void> {
10
11
  return new Promise((r) => setTimeout(r, ms));
@@ -554,6 +555,7 @@ export function useBundleManager({
554
555
  const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
555
556
  const [error, setError] = React.useState<string | null>(null);
556
557
  const [isTesting, setIsTesting] = React.useState(false);
558
+ const didHydrateColdStartRef = React.useRef(false);
557
559
 
558
560
  const baseRef = React.useRef(base);
559
561
  baseRef.current = base;
@@ -583,20 +585,27 @@ export function useBundleManager({
583
585
  }, [canRequestLatest]);
584
586
  // Track the most recently successfully loaded base bundle so we can instantly exit test mode.
585
587
  const lastBaseBundlePathRef = React.useRef<string | null>(null);
588
+ const lastBaseAppIdRef = React.useRef<string | null>(null);
586
589
  const lastBaseFingerprintRef = React.useRef<string | null>(null);
590
+ const activeBundleAppIdRef = React.useRef<string | null>(null);
587
591
  // Only used to suppress an unnecessary remount on cold start when the network bundle matches the disk bundle.
588
592
  const initialHydratedBaseFromDiskRef = React.useRef(false);
589
593
  const hasCompletedFirstNetworkBaseLoadRef = React.useRef(false);
590
594
 
591
595
  const hydrateBaseFromDisk = React.useCallback(
592
- async (appId: string, reason: 'initial' | 'fallback') => {
596
+ async (
597
+ appId: string,
598
+ reason: 'initial' | 'fallback',
599
+ options?: { allowEmbeddedFallback?: boolean }
600
+ ) => {
593
601
  try {
594
602
  const dir = bundlesCacheDir();
595
603
  await ensureDir(dir);
596
604
  const key = baseBundleKey(appId, platform);
597
605
  let existing = await getExistingBundleFileUri(key, platform);
598
606
  let embeddedMeta: BaseBundleMeta | null = null;
599
- if (!existing) {
607
+ const allowEmbeddedFallback = options?.allowEmbeddedFallback ?? false;
608
+ if (!existing && allowEmbeddedFallback) {
600
609
  const embedded = embeddedBaseBundlesRef.current?.[platform];
601
610
  const hydrated = await hydrateBaseFromEmbeddedAsset(appId, platform, embedded);
602
611
  if (hydrated?.bundlePath) {
@@ -611,6 +620,8 @@ export function useBundleManager({
611
620
  }
612
621
  if (existing) {
613
622
  lastBaseBundlePathRef.current = existing;
623
+ lastBaseAppIdRef.current = appId;
624
+ activeBundleAppIdRef.current = appId;
614
625
  setBundlePath(existing);
615
626
  const meta =
616
627
  embeddedMeta ??
@@ -633,6 +644,9 @@ export function useBundleManager({
633
644
  initialHydratedBaseFromDiskRef.current = true;
634
645
  hasCompletedFirstNetworkBaseLoadRef.current = false;
635
646
  }
647
+ } else if (activeBundleAppIdRef.current !== appId) {
648
+ activeBundleAppIdRef.current = null;
649
+ setBundlePath(null);
636
650
  }
637
651
  } catch {
638
652
 
@@ -641,24 +655,42 @@ export function useBundleManager({
641
655
  [platform]
642
656
  );
643
657
 
644
- // On cold reopen, try to load the last base bundle from disk as early as possible.
658
+ // On cold open, hydrate from disk and allow embedded fallback once.
659
+ // On subsequent app switches, hydrate only app-specific disk cache (no embedded fallback).
645
660
  React.useEffect(() => {
646
661
  if (!base.appId) return;
647
662
  initialHydratedBaseFromDiskRef.current = false;
648
663
  hasCompletedFirstNetworkBaseLoadRef.current = false;
649
- void hydrateBaseFromDisk(base.appId, 'initial');
664
+ const isColdStart = !didHydrateColdStartRef.current;
665
+ didHydrateColdStartRef.current = true;
666
+ void hydrateBaseFromDisk(base.appId, isColdStart ? 'initial' : 'fallback', {
667
+ allowEmbeddedFallback: isColdStart,
668
+ });
650
669
  }, [base.appId, platform, hydrateBaseFromDisk]);
651
670
 
671
+ // Never keep rendering a bundle that belongs to a different app.
672
+ React.useEffect(() => {
673
+ if (!base.appId) return;
674
+ if (activeBundleAppIdRef.current && activeBundleAppIdRef.current !== base.appId) {
675
+ activeBundleAppIdRef.current = null;
676
+ setBundlePath(null);
677
+ setIsTesting(false);
678
+ setError(null);
679
+ setStatusLabel(null);
680
+ }
681
+ }, [base.appId]);
682
+
652
683
  const activateCachedBase = React.useCallback(
653
684
  async (appId: string) => {
654
685
  setIsTesting(false);
655
686
  setStatusLabel(null);
656
687
  setError(null);
657
- const cachedBase = lastBaseBundlePathRef.current;
688
+ const cachedBase = lastBaseAppIdRef.current === appId ? lastBaseBundlePathRef.current : null;
658
689
  if (cachedBase) {
690
+ activeBundleAppIdRef.current = appId;
659
691
  setBundlePath(cachedBase);
660
692
  } else {
661
- await hydrateBaseFromDisk(appId, 'fallback');
693
+ await hydrateBaseFromDisk(appId, 'fallback', { allowEmbeddedFallback: false });
662
694
  }
663
695
  },
664
696
  [hydrateBaseFromDisk]
@@ -699,6 +731,7 @@ export function useBundleManager({
699
731
  if (mode === 'test' && opId !== testOpIdRef.current) return;
700
732
  if (desiredModeRef.current !== mode) return;
701
733
  setBundleStatus(bundle.status);
734
+ activeBundleAppIdRef.current = src.appId;
702
735
  setBundlePath(path);
703
736
  const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
704
737
 
@@ -717,6 +750,7 @@ export function useBundleManager({
717
750
 
718
751
  if (mode === 'base') {
719
752
  lastBaseBundlePathRef.current = path;
753
+ lastBaseAppIdRef.current = src.appId;
720
754
  lastBaseFingerprintRef.current = fingerprint;
721
755
  hasCompletedFirstNetworkBaseLoadRef.current = true;
722
756
  initialHydratedBaseFromDiskRef.current = false;
@@ -762,7 +796,22 @@ export function useBundleManager({
762
796
  }, [load]);
763
797
 
764
798
  const loadTest = React.useCallback(async (src: BundleSource) => {
765
- await load(src, 'test');
799
+ try {
800
+ await load(src, 'test');
801
+ await trackTestBundle({
802
+ appId: src.appId,
803
+ commitId: src.commitId ?? undefined,
804
+ success: true,
805
+ });
806
+ } catch (error) {
807
+ await trackTestBundle({
808
+ appId: src.appId,
809
+ commitId: src.commitId ?? undefined,
810
+ success: false,
811
+ error,
812
+ });
813
+ throw error;
814
+ }
766
815
  }, [load]);
767
816
 
768
817
  const restoreBase = React.useCallback(async () => {
@@ -5,6 +5,11 @@ import { mergeRequestsRepository } from '../../data/merge-requests/repository';
5
5
  import type { MergeRequestSummary } from '../../components/models/types';
6
6
  import { usersRepository } from '../../data/users/repository';
7
7
  import type { UserStats } from '../../data/users/types';
8
+ import {
9
+ trackApproveMergeRequest,
10
+ trackOpenMergeRequest,
11
+ trackRejectMergeRequest,
12
+ } from '../analytics/track';
8
13
 
9
14
  export type MergeRequestLists = {
10
15
  /**
@@ -118,27 +123,71 @@ export function useMergeRequests(params: { appId: string }): UseMergeRequestsRes
118
123
 
119
124
  React.useEffect(() => {
120
125
  void refresh();
121
- }, [refresh]);
126
+ }, [appId, refresh]);
122
127
 
123
128
  const openMergeRequest = React.useCallback(async (sourceAppId: string) => {
124
- const mr = await mergeRequestsRepository.open({ sourceAppId });
125
- await refresh();
126
- return mr;
129
+ try {
130
+ const mr = await mergeRequestsRepository.open({ sourceAppId });
131
+ await refresh();
132
+ await trackOpenMergeRequest({
133
+ appId,
134
+ mergeRequestId: mr.id,
135
+ success: true,
136
+ });
137
+ return mr;
138
+ } catch (error) {
139
+ await trackOpenMergeRequest({
140
+ appId,
141
+ success: false,
142
+ error,
143
+ });
144
+ throw error;
145
+ }
127
146
  }, [refresh]);
128
147
 
129
148
  const approve = React.useCallback(async (mrId: string) => {
130
- const mr = await mergeRequestsRepository.update(mrId, { status: 'approved' });
131
- await refresh();
132
- const merged = await pollUntilMerged(mrId);
133
- await refresh();
134
- return merged ?? mr;
135
- }, [pollUntilMerged, refresh]);
149
+ try {
150
+ const mr = await mergeRequestsRepository.update(mrId, { status: 'approved' });
151
+ await refresh();
152
+ const merged = await pollUntilMerged(mrId);
153
+ await refresh();
154
+ await trackApproveMergeRequest({
155
+ appId,
156
+ mergeRequestId: mrId,
157
+ success: true,
158
+ });
159
+ return merged ?? mr;
160
+ } catch (error) {
161
+ await trackApproveMergeRequest({
162
+ appId,
163
+ mergeRequestId: mrId,
164
+ success: false,
165
+ error,
166
+ });
167
+ throw error;
168
+ }
169
+ }, [appId, pollUntilMerged, refresh]);
136
170
 
137
171
  const reject = React.useCallback(async (mrId: string) => {
138
- const mr = await mergeRequestsRepository.update(mrId, { status: 'rejected' });
139
- await refresh();
140
- return mr;
141
- }, [refresh]);
172
+ try {
173
+ const mr = await mergeRequestsRepository.update(mrId, { status: 'rejected' });
174
+ await refresh();
175
+ await trackRejectMergeRequest({
176
+ appId,
177
+ mergeRequestId: mrId,
178
+ success: true,
179
+ });
180
+ return mr;
181
+ } catch (error) {
182
+ await trackRejectMergeRequest({
183
+ appId,
184
+ mergeRequestId: mrId,
185
+ success: false,
186
+ error,
187
+ });
188
+ throw error;
189
+ }
190
+ }, [appId, refresh]);
142
191
 
143
192
  const toSummary = React.useCallback((mr: MergeRequest): MergeRequestSummary => {
144
193
  const stats = creatorStatsById[mr.createdBy];