@cat-factory/app 0.153.3 → 0.153.4

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,60 @@
1
+ import type { ResyncRequest } from '~/types/domain'
2
+ import type { GitHubStoreContext } from './context'
3
+
4
+ /**
5
+ * The App-installation lifecycle: discovering/binding an installation, dropping it, and
6
+ * triggering a resync of the projections. Extracted from the store setup; each operation closes
7
+ * over the shared {@link GitHubStoreContext} so behaviour is identical to the original
8
+ * in-closure functions — the split is purely to keep every function within the size budget.
9
+ */
10
+ export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
11
+ const { api, workspace, available, connection, installations, loadingInstallations } = ctx
12
+ const { repos, availableRepos, pulls, issues, branches, syncing, load } = ctx
13
+
14
+ /** The URL a workspace owner visits to install the App against this workspace. */
15
+ function getInstallUrl(): Promise<string> {
16
+ return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
17
+ }
18
+
19
+ /** Discover the App's installations so the user can connect one without typing an id. */
20
+ async function loadInstallations() {
21
+ loadingInstallations.value = true
22
+ try {
23
+ const { installations: list } = await api.listGitHubInstallations(workspace.requireId())
24
+ installations.value = list
25
+ } finally {
26
+ loadingInstallations.value = false
27
+ }
28
+ }
29
+
30
+ /** Programmatic bind by installation id (the browser flow uses the redirect). */
31
+ async function connect(installationId: number) {
32
+ connection.value = await api.connectGitHub(workspace.requireId(), installationId)
33
+ available.value = true
34
+ await load()
35
+ }
36
+
37
+ async function disconnect() {
38
+ await api.disconnectGitHub(workspace.requireId())
39
+ connection.value = null
40
+ repos.value = []
41
+ availableRepos.value = []
42
+ pulls.value = []
43
+ issues.value = []
44
+ branches.value = {}
45
+ }
46
+
47
+ /** Trigger a resync, then refresh projections (no-op for queued/backfill). */
48
+ async function resync(body: ResyncRequest = {}) {
49
+ syncing.value = true
50
+ try {
51
+ const res = await api.resyncGitHub(workspace.requireId(), body)
52
+ await load()
53
+ return res
54
+ } finally {
55
+ syncing.value = false
56
+ }
57
+ }
58
+
59
+ return { getInstallUrl, loadInstallations, connect, disconnect, resync }
60
+ }
@@ -0,0 +1,46 @@
1
+ import type { Ref } from 'vue'
2
+ import type {
3
+ GitHubBranch,
4
+ GitHubConnection,
5
+ GitHubAvailableRepo,
6
+ GitHubInstallationOption,
7
+ GitHubIssue,
8
+ GitHubPullRequest,
9
+ GitHubRepo,
10
+ RepoTreeEntry,
11
+ } from '~/types/domain'
12
+ import { useWorkspaceStore } from '~/stores/workspace'
13
+
14
+ /** Stable identity for a pull request in the `pulls` list: repo + PR number. */
15
+ export const pullKey = (repoGithubId: number, number: number) => `${repoGithubId}:${number}`
16
+
17
+ /**
18
+ * Shared reactive state + injected dependencies the GitHub-store action factories close over.
19
+ * Created once in the `github` store setup and threaded into {@link createGitHubConnectionActions}
20
+ * and {@link createGitHubRepoActions} so the split operations stay behaviourally identical to the
21
+ * original single-closure store — a size-only extraction mirroring `stores/board/` and
22
+ * `stores/pipelines/`, not a new seam.
23
+ */
24
+ export interface GitHubStoreContext {
25
+ api: ReturnType<typeof useApi>
26
+ workspace: ReturnType<typeof useWorkspaceStore>
27
+ available: Ref<boolean | null>
28
+ connection: Ref<GitHubConnection | null>
29
+ installations: Ref<GitHubInstallationOption[]>
30
+ loadingInstallations: Ref<boolean>
31
+ repos: Ref<GitHubRepo[]>
32
+ availableRepos: Ref<GitHubAvailableRepo[]>
33
+ loadingAvailable: Ref<boolean>
34
+ savingRepos: Ref<boolean>
35
+ pulls: Ref<GitHubPullRequest[]>
36
+ upsertPull: (pull: GitHubPullRequest) => void
37
+ getPull: (key: string) => GitHubPullRequest | undefined
38
+ issues: Ref<GitHubIssue[]>
39
+ branches: Ref<Record<number, GitHubBranch[]>>
40
+ repoFiles: Ref<Record<number, RepoTreeEntry[]>>
41
+ syncing: Ref<boolean>
42
+ /** Whether an App installation is bound (the store's `connected` computed). */
43
+ connected: Readonly<Ref<boolean>>
44
+ /** Reload the cached repos/PRs/issues projection (the store's `load`). */
45
+ load: () => Promise<void>
46
+ }
@@ -0,0 +1,151 @@
1
+ import type {
2
+ CreateBranchInput,
3
+ GitHubAvailableRepo,
4
+ GitHubBranch,
5
+ MergePullRequestInput,
6
+ OpenPullRequestInput,
7
+ RepoTreeEntry,
8
+ } from '~/types/domain'
9
+ import { pullKey, type GitHubStoreContext } from './context'
10
+
11
+ /**
12
+ * The per-repo reads (link picker, branches, trees, file listings) and writes (create repo /
13
+ * branch, open / merge a PR, comment). Extracted from the store setup; each operation closes
14
+ * over the shared {@link GitHubStoreContext} so behaviour is identical to the original
15
+ * in-closure functions — the split is purely to keep every function within the size budget.
16
+ */
17
+ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
18
+ const { api, workspace, connected, repos, availableRepos, loadingAvailable, savingRepos } = ctx
19
+ const { branches, repoFiles, upsertPull, getPull, load } = ctx
20
+
21
+ /**
22
+ * Load the repos the installation can access, with this workspace's link state.
23
+ * With a `q` the backend filters `owner/name` server-side (the add-service picker
24
+ * searches instead of prefetching a huge installation); without one it browses all
25
+ * (the repo-link panel). A blank/short `q` clears the list rather than fetching.
26
+ */
27
+ async function loadAvailableRepos(q?: string) {
28
+ if (!connected.value) return
29
+ if (q !== undefined && q.trim() === '') {
30
+ availableRepos.value = []
31
+ return
32
+ }
33
+ loadingAvailable.value = true
34
+ try {
35
+ availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId(), q)
36
+ } finally {
37
+ loadingAvailable.value = false
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Search the installation/PAT-accessible repos server-side WITHOUT touching the shared
43
+ * `availableRepos`/`loadingAvailable` singleton — it returns the matches to the caller instead.
44
+ * This is the reusable form behind `useRepoSearch`: two independent pickers (the add-service
45
+ * modal and the doc-task reference-repo picker) can search concurrently without clobbering
46
+ * each other's results. A blank/short `q` (or no connection) returns `[]`.
47
+ */
48
+ async function searchAvailableRepos(q: string): Promise<GitHubAvailableRepo[]> {
49
+ if (!connected.value || q.trim() === '') return []
50
+ return api.listGitHubAvailableRepos(workspace.requireId(), q)
51
+ }
52
+
53
+ /** Set the exact set of repos this workspace links, then refresh projections. */
54
+ async function setLinkedRepos(repoGithubIds: number[]) {
55
+ savingRepos.value = true
56
+ try {
57
+ repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
58
+ // Reflect the new link state in the picker and refresh PRs/issues.
59
+ const linked = new Set(repoGithubIds)
60
+ availableRepos.value = availableRepos.value.map((r) => ({
61
+ ...r,
62
+ linked: linked.has(r.githubId),
63
+ }))
64
+ await load()
65
+ } finally {
66
+ savingRepos.value = false
67
+ }
68
+ }
69
+
70
+ /** Lazily load (and cache) the branches for a single repo. */
71
+ async function loadBranches(repoGithubId: number): Promise<GitHubBranch[]> {
72
+ const list = await api.listGitHubBranches(workspace.requireId(), repoGithubId)
73
+ branches.value = { ...branches.value, [repoGithubId]: list }
74
+ return list
75
+ }
76
+
77
+ /** List one level of a (monorepo) repo's tree, for the service-directory picker. */
78
+ function loadRepoTree(repoGithubId: number, path = '') {
79
+ return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
80
+ }
81
+
82
+ /**
83
+ * Load (and cache) EVERY file in a repo — the whole tree in one recursive read — so a
84
+ * picker can search files by path entirely client-side (no per-keystroke server call).
85
+ * Cached by repo id so re-opening the picker for the same repo is instant; force a
86
+ * refetch with `{ reload: true }`. Mirrors the branches cache.
87
+ */
88
+ async function loadRepoFiles(
89
+ repoGithubId: number,
90
+ opts: { reload?: boolean } = {},
91
+ ): Promise<RepoTreeEntry[]> {
92
+ const cached = repoFiles.value[repoGithubId]
93
+ if (cached && !opts.reload) return cached
94
+ const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
95
+ repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
96
+ return files
97
+ }
98
+
99
+ // ---- repo writes ----------------------------------------------------------
100
+
101
+ /**
102
+ * Create a repository under the connected account (privileged App tier). Only
103
+ * meaningful when `canCreateRepos`; the backend 409s otherwise. Returns the
104
+ * created repo so the caller can confirm/link it.
105
+ */
106
+ function createRepo(input: Parameters<typeof api.createGitHubRepo>[1]) {
107
+ return api.createGitHubRepo(workspace.requireId(), input)
108
+ }
109
+
110
+ async function createBranch(repoGithubId: number, input: CreateBranchInput) {
111
+ const branch = await api.createGitHubBranch(workspace.requireId(), repoGithubId, input)
112
+ const next = branches.value[repoGithubId] ?? []
113
+ branches.value = { ...branches.value, [repoGithubId]: [branch, ...next] }
114
+ return branch
115
+ }
116
+
117
+ async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
118
+ const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
119
+ upsertPull(pr)
120
+ return pr
121
+ }
122
+
123
+ async function mergePullRequest(
124
+ repoGithubId: number,
125
+ number: number,
126
+ input: MergePullRequestInput = {},
127
+ ) {
128
+ await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
129
+ // Optimistically reflect the merge until the next sync confirms it.
130
+ const existing = getPull(pullKey(repoGithubId, number))
131
+ if (existing) upsertPull({ ...existing, state: 'closed', merged: true })
132
+ }
133
+
134
+ function comment(repoGithubId: number, number: number, body: string) {
135
+ return api.commentGitHubIssue(workspace.requireId(), repoGithubId, number, body)
136
+ }
137
+
138
+ return {
139
+ loadAvailableRepos,
140
+ searchAvailableRepos,
141
+ setLinkedRepos,
142
+ loadBranches,
143
+ loadRepoTree,
144
+ loadRepoFiles,
145
+ createRepo,
146
+ createBranch,
147
+ openPullRequest,
148
+ mergePullRequest,
149
+ comment,
150
+ }
151
+ }
@@ -1,7 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import type {
4
- CreateBranchInput,
5
4
  GitHubAvailableRepo,
6
5
  GitHubBranch,
7
6
  GitHubConnection,
@@ -9,18 +8,15 @@ import type {
9
8
  GitHubIssue,
10
9
  GitHubPullRequest,
11
10
  GitHubRepo,
12
- MergePullRequestInput,
13
- OpenPullRequestInput,
14
11
  RepoTreeEntry,
15
- ResyncRequest,
16
12
  } from '~/types/domain'
17
13
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
18
14
  import { useUpsertList } from '~/composables/useUpsertList'
19
15
  import { useWorkspaceStore } from '~/stores/workspace'
20
16
  import { useServicesStore } from '~/stores/services'
21
-
22
- /** Stable identity for a pull request in the `pulls` list: repo + PR number. */
23
- const pullKey = (repoGithubId: number, number: number) => `${repoGithubId}:${number}`
17
+ import { pullKey, type GitHubStoreContext } from '~/stores/github/context'
18
+ import { createGitHubConnectionActions } from '~/stores/github/connection'
19
+ import { createGitHubRepoActions } from '~/stores/github/repoActions'
24
20
 
25
21
  /**
26
22
  * GitHub integration state: the workspace's App installation, the projected
@@ -155,170 +151,35 @@ export const useGitHubStore = defineStore('github', () => {
155
151
  if (connected.value && repos.value.length === 0) await load()
156
152
  }
157
153
 
158
- /**
159
- * Load the repos the installation can access, with this workspace's link state.
160
- * With a `q` the backend filters `owner/name` server-side (the add-service picker
161
- * searches instead of prefetching a huge installation); without one it browses all
162
- * (the repo-link panel). A blank/short `q` clears the list rather than fetching.
163
- */
164
- async function loadAvailableRepos(q?: string) {
165
- if (!connected.value) return
166
- if (q !== undefined && q.trim() === '') {
167
- availableRepos.value = []
168
- return
169
- }
170
- loadingAvailable.value = true
171
- try {
172
- availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId(), q)
173
- } finally {
174
- loadingAvailable.value = false
175
- }
176
- }
177
-
178
- /**
179
- * Search the installation/PAT-accessible repos server-side WITHOUT touching the shared
180
- * `availableRepos`/`loadingAvailable` singleton — it returns the matches to the caller instead.
181
- * This is the reusable form behind {@link useRepoSearch}: two independent pickers (the
182
- * add-service modal and the doc-task reference-repo picker) can search concurrently without
183
- * clobbering each other's results. A blank/short `q` (or no connection) returns `[]`.
184
- */
185
- async function searchAvailableRepos(q: string): Promise<GitHubAvailableRepo[]> {
186
- if (!connected.value || q.trim() === '') return []
187
- return api.listGitHubAvailableRepos(workspace.requireId(), q)
188
- }
189
-
190
- /** Set the exact set of repos this workspace links, then refresh projections. */
191
- async function setLinkedRepos(repoGithubIds: number[]) {
192
- savingRepos.value = true
193
- try {
194
- repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
195
- // Reflect the new link state in the picker and refresh PRs/issues.
196
- const linked = new Set(repoGithubIds)
197
- availableRepos.value = availableRepos.value.map((r) => ({
198
- ...r,
199
- linked: linked.has(r.githubId),
200
- }))
201
- await load()
202
- } finally {
203
- savingRepos.value = false
204
- }
205
- }
206
-
207
- /** Lazily load (and cache) the branches for a single repo. */
208
- async function loadBranches(repoGithubId: number): Promise<GitHubBranch[]> {
209
- const list = await api.listGitHubBranches(workspace.requireId(), repoGithubId)
210
- branches.value = { ...branches.value, [repoGithubId]: list }
211
- return list
212
- }
213
-
214
- /** List one level of a (monorepo) repo's tree, for the service-directory picker. */
215
- function loadRepoTree(repoGithubId: number, path = '') {
216
- return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
217
- }
218
-
219
154
  /** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
220
155
  const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
221
156
 
222
- /**
223
- * Load (and cache) EVERY file in a repo the whole tree in one recursive read so a
224
- * picker can search files by path entirely client-side (no per-keystroke server call).
225
- * Cached by repo id so re-opening the picker for the same repo is instant; force a
226
- * refetch with `{ reload: true }`. Mirrors the branches cache.
227
- */
228
- async function loadRepoFiles(
229
- repoGithubId: number,
230
- opts: { reload?: boolean } = {},
231
- ): Promise<RepoTreeEntry[]> {
232
- const cached = repoFiles.value[repoGithubId]
233
- if (cached && !opts.reload) return cached
234
- const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
235
- repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
236
- return files
237
- }
238
-
239
- /** The URL a workspace owner visits to install the App against this workspace. */
240
- function getInstallUrl(): Promise<string> {
241
- return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
242
- }
243
-
244
- /** Discover the App's installations so the user can connect one without typing an id. */
245
- async function loadInstallations() {
246
- loadingInstallations.value = true
247
- try {
248
- const { installations: list } = await api.listGitHubInstallations(workspace.requireId())
249
- installations.value = list
250
- } finally {
251
- loadingInstallations.value = false
252
- }
253
- }
254
-
255
- /** Programmatic bind by installation id (the browser flow uses the redirect). */
256
- async function connect(installationId: number) {
257
- connection.value = await api.connectGitHub(workspace.requireId(), installationId)
258
- available.value = true
259
- await load()
260
- }
261
-
262
- async function disconnect() {
263
- await api.disconnectGitHub(workspace.requireId())
264
- connection.value = null
265
- repos.value = []
266
- availableRepos.value = []
267
- pulls.value = []
268
- issues.value = []
269
- branches.value = {}
270
- }
271
-
272
- /** Trigger a resync, then refresh projections (no-op for queued/backfill). */
273
- async function resync(body: ResyncRequest = {}) {
274
- syncing.value = true
275
- try {
276
- const res = await api.resyncGitHub(workspace.requireId(), body)
277
- await load()
278
- return res
279
- } finally {
280
- syncing.value = false
281
- }
282
- }
283
-
284
- // ---- repo writes ----------------------------------------------------------
285
-
286
- /**
287
- * Create a repository under the connected account (privileged App tier). Only
288
- * meaningful when `canCreateRepos`; the backend 409s otherwise. Returns the
289
- * created repo so the caller can confirm/link it.
290
- */
291
- function createRepo(input: Parameters<typeof api.createGitHubRepo>[1]) {
292
- return api.createGitHubRepo(workspace.requireId(), input)
293
- }
294
-
295
- async function createBranch(repoGithubId: number, input: CreateBranchInput) {
296
- const branch = await api.createGitHubBranch(workspace.requireId(), repoGithubId, input)
297
- const next = branches.value[repoGithubId] ?? []
298
- branches.value = { ...branches.value, [repoGithubId]: [branch, ...next] }
299
- return branch
300
- }
301
-
302
- async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
303
- const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
304
- upsertPull(pr)
305
- return pr
306
- }
307
-
308
- async function mergePullRequest(
309
- repoGithubId: number,
310
- number: number,
311
- input: MergePullRequestInput = {},
312
- ) {
313
- await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
314
- // Optimistically reflect the merge until the next sync confirms it.
315
- const existing = getPull(pullKey(repoGithubId, number))
316
- if (existing) upsertPull({ ...existing, state: 'closed', merged: true })
317
- }
318
-
319
- function comment(repoGithubId: number, number: number, body: string) {
320
- return api.commentGitHubIssue(workspace.requireId(), repoGithubId, number, body)
157
+ // The installation lifecycle + the per-repo reads/writes, split into cohesive factories
158
+ // sharing the state above (a size-only extraction mirroring `stores/board/`behaviour is
159
+ // identical to the former in-closure functions).
160
+ const context: GitHubStoreContext = {
161
+ api,
162
+ workspace,
163
+ available,
164
+ connection,
165
+ installations,
166
+ loadingInstallations,
167
+ repos,
168
+ availableRepos,
169
+ loadingAvailable,
170
+ savingRepos,
171
+ pulls,
172
+ upsertPull,
173
+ getPull,
174
+ issues,
175
+ branches,
176
+ repoFiles,
177
+ syncing,
178
+ connected,
179
+ load,
321
180
  }
181
+ const connectionActions = createGitHubConnectionActions(context)
182
+ const repoActions = createGitHubRepoActions(context)
322
183
 
323
184
  /**
324
185
  * Drop the per-workspace projection + connection state (called on workspace switch)
@@ -365,23 +226,9 @@ export const useGitHubStore = defineStore('github', () => {
365
226
  ensureProbed,
366
227
  load,
367
228
  ensureLoaded,
368
- loadAvailableRepos,
369
- searchAvailableRepos,
370
- setLinkedRepos,
371
- loadRepoTree,
372
229
  repoFiles,
373
- loadRepoFiles,
374
- loadBranches,
375
- getInstallUrl,
376
- loadInstallations,
377
- connect,
378
- disconnect,
379
- resync,
380
- createRepo,
381
- createBranch,
382
- openPullRequest,
383
- mergePullRequest,
384
- comment,
230
+ ...connectionActions,
231
+ ...repoActions,
385
232
  reset,
386
233
  }
387
234
  })
@@ -0,0 +1,89 @@
1
+ import { ref } from 'vue'
2
+ import type {
3
+ InitiativeExecutionPolicy,
4
+ PromoteInitiativeFollowUpInput,
5
+ UpdateInitiativeItemInput,
6
+ } from '~/types/domain'
7
+ import type { InitiativeActionContext } from './planning'
8
+
9
+ /**
10
+ * The tracker-curation actions: promoting / dismissing a harvested follow-up, editing one
11
+ * tracker item (or driving its status), and replacing the execution policy. Every one runs
12
+ * through the shared `curate` wrapper so the window has a single in-flight flag.
13
+ */
14
+ export function createInitiativeCurationActions(ctx: InitiativeActionContext) {
15
+ const { api, workspace, upsert } = ctx
16
+
17
+ /** True while a curation action (promote/dismiss/edit item/edit policy) is in flight. */
18
+ const curating = ref(false)
19
+
20
+ async function curate<T>(fn: () => Promise<T>): Promise<T> {
21
+ if (!workspace.workspaceId) throw new Error('No active workspace')
22
+ curating.value = true
23
+ try {
24
+ return await fn()
25
+ } finally {
26
+ curating.value = false
27
+ }
28
+ }
29
+
30
+ /** Promote an `open` harvested follow-up into a new pending tracker item. */
31
+ async function promoteFollowUp(
32
+ initiativeId: string,
33
+ followUpId: string,
34
+ input: PromoteInitiativeFollowUpInput,
35
+ ) {
36
+ return curate(async () => {
37
+ const updated = await api.promoteInitiativeFollowUp(
38
+ workspace.workspaceId!,
39
+ initiativeId,
40
+ followUpId,
41
+ input,
42
+ )
43
+ upsert(updated)
44
+ return updated
45
+ })
46
+ }
47
+
48
+ /** Dismiss a harvested follow-up. */
49
+ async function dismissFollowUp(initiativeId: string, followUpId: string) {
50
+ return curate(async () => {
51
+ const updated = await api.dismissInitiativeFollowUp(
52
+ workspace.workspaceId!,
53
+ initiativeId,
54
+ followUpId,
55
+ )
56
+ upsert(updated)
57
+ return updated
58
+ })
59
+ }
60
+
61
+ /** Edit one tracker item and/or drive its status (retry a blocked item / skip it). */
62
+ async function updateItem(
63
+ initiativeId: string,
64
+ itemId: string,
65
+ input: UpdateInitiativeItemInput,
66
+ ) {
67
+ return curate(async () => {
68
+ const updated = await api.updateInitiativeItem(
69
+ workspace.workspaceId!,
70
+ initiativeId,
71
+ itemId,
72
+ input,
73
+ )
74
+ upsert(updated)
75
+ return updated
76
+ })
77
+ }
78
+
79
+ /** Replace the execution policy (concurrency + pipeline rules). */
80
+ async function updatePolicy(initiativeId: string, policy: InitiativeExecutionPolicy) {
81
+ return curate(async () => {
82
+ const updated = await api.updateInitiativePolicy(workspace.workspaceId!, initiativeId, policy)
83
+ upsert(updated)
84
+ return updated
85
+ })
86
+ }
87
+
88
+ return { curating, promoteFollowUp, dismissFollowUp, updateItem, updatePolicy }
89
+ }