@dxos/plugin-github 0.13.0 → 0.14.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.
Files changed (43) hide show
  1. package/dist/lib/GitHubOperation.mjs +27 -1
  2. package/dist/lib/GitHubOperation.mjs.map +1 -1
  3. package/dist/lib/chunk-PullRequestArticle.mjs +31 -11
  4. package/dist/lib/chunk-PullRequestArticle.mjs.map +1 -1
  5. package/dist/lib/chunk-import-pull-request.mjs +78 -3
  6. package/dist/lib/chunk-import-pull-request.mjs.map +1 -1
  7. package/dist/lib/chunk-refresh-pull-requests.mjs +49 -0
  8. package/dist/lib/chunk-refresh-pull-requests.mjs.map +1 -0
  9. package/dist/lib/chunk-sync-pull-request.mjs +11 -9
  10. package/dist/lib/chunk-sync-pull-request.mjs.map +1 -1
  11. package/dist/lib/operations.mjs +2 -1
  12. package/dist/lib/operations.mjs.map +1 -1
  13. package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts +5 -2
  14. package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts.map +1 -1
  15. package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts +2 -0
  16. package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts.map +1 -0
  17. package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts.map +1 -1
  18. package/dist/types/src/operations/import-pull-request.d.ts.map +1 -1
  19. package/dist/types/src/operations/index.d.ts.map +1 -1
  20. package/dist/types/src/operations/refresh-pull-requests.d.ts +5 -0
  21. package/dist/types/src/operations/refresh-pull-requests.d.ts.map +1 -0
  22. package/dist/types/src/operations/refresh.d.ts +37 -0
  23. package/dist/types/src/operations/refresh.d.ts.map +1 -0
  24. package/dist/types/src/operations/refresh.test.d.ts +2 -0
  25. package/dist/types/src/operations/refresh.test.d.ts.map +1 -0
  26. package/dist/types/src/operations/sync-pull-request.d.ts.map +1 -1
  27. package/dist/types/src/stories/PullRequestOverview.stories.d.ts.map +1 -1
  28. package/dist/types/src/types/GitHubOperation.d.ts +11 -0
  29. package/dist/types/src/types/GitHubOperation.d.ts.map +1 -1
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +42 -42
  32. package/src/components/PullRequestOverview/ArtifactPill.tsx +1 -1
  33. package/src/components/PullRequestOverview/CheckRunList.test.ts +29 -0
  34. package/src/components/PullRequestOverview/CheckRunList.tsx +28 -7
  35. package/src/components/PullRequestStatus/PullRequestStatus.tsx +8 -5
  36. package/src/operations/import-pull-request.ts +8 -0
  37. package/src/operations/index.ts +1 -0
  38. package/src/operations/refresh-pull-requests.ts +69 -0
  39. package/src/operations/refresh.test.ts +112 -0
  40. package/src/operations/refresh.ts +111 -0
  41. package/src/operations/sync-pull-request.ts +10 -13
  42. package/src/stories/PullRequestOverview.stories.tsx +2 -1
  43. package/src/types/GitHubOperation.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/plugin-github",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "DXOS plugin that integrates GitHub as a sync source via the generic Integration mechanism.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -134,42 +134,42 @@
134
134
  "@codemirror/state": "^6.7.6",
135
135
  "@codemirror/view": "^6.43.13",
136
136
  "effect": "4.0.0",
137
- "@dxos/ai": "0.13.0",
138
- "@dxos/app-framework": "0.13.0",
139
- "@dxos/app-graph": "0.13.0",
140
- "@dxos/app-toolkit": "0.13.0",
141
- "@dxos/compute": "0.13.0",
142
- "@dxos/echo": "0.13.0",
143
- "@dxos/echo-query": "0.13.0",
144
- "@dxos/echo-react": "0.13.0",
145
- "@dxos/effect": "0.13.0",
146
- "@dxos/errors": "0.13.0",
147
- "@dxos/graph": "0.13.0",
148
- "@dxos/keys": "0.13.0",
149
- "@dxos/link": "0.13.0",
150
- "@dxos/lit-ui": "0.13.0",
151
- "@dxos/log": "0.13.0",
152
- "@dxos/plugin-connector": "0.13.0",
153
- "@dxos/plugin-crx": "0.13.0",
154
- "@dxos/plugin-markdown": "0.13.0",
155
- "@dxos/plugin-preview": "0.13.0",
156
- "@dxos/plugin-space": "0.13.0",
157
- "@dxos/protocols": "0.13.0",
158
- "@dxos/react-client": "0.13.0",
159
- "@dxos/react-ui": "0.13.0",
160
- "@dxos/react-ui-attention": "0.13.0",
161
- "@dxos/react-ui-components": "0.13.0",
162
- "@dxos/react-ui-editor": "0.13.0",
163
- "@dxos/react-ui-form": "0.13.0",
164
- "@dxos/react-ui-list": "0.13.0",
165
- "@dxos/react-ui-markdown": "0.13.0",
166
- "@dxos/react-ui-masonry": "0.13.0",
167
- "@dxos/react-ui-menu": "0.13.0",
168
- "@dxos/react-ui-query": "0.13.0",
169
- "@dxos/types": "0.13.0",
170
- "@dxos/ui-editor": "0.13.0",
171
- "@dxos/ui-types": "0.13.0",
172
- "@dxos/util": "0.13.0"
137
+ "@dxos/ai": "0.14.0",
138
+ "@dxos/app-framework": "0.14.0",
139
+ "@dxos/app-graph": "0.14.0",
140
+ "@dxos/app-toolkit": "0.14.0",
141
+ "@dxos/compute": "0.14.0",
142
+ "@dxos/echo": "0.14.0",
143
+ "@dxos/echo-query": "0.14.0",
144
+ "@dxos/echo-react": "0.14.0",
145
+ "@dxos/effect": "0.14.0",
146
+ "@dxos/errors": "0.14.0",
147
+ "@dxos/graph": "0.14.0",
148
+ "@dxos/keys": "0.14.0",
149
+ "@dxos/link": "0.14.0",
150
+ "@dxos/lit-ui": "0.14.0",
151
+ "@dxos/log": "0.14.0",
152
+ "@dxos/plugin-connector": "0.14.0",
153
+ "@dxos/plugin-crx": "0.14.0",
154
+ "@dxos/plugin-markdown": "0.14.0",
155
+ "@dxos/plugin-preview": "0.14.0",
156
+ "@dxos/plugin-space": "0.14.0",
157
+ "@dxos/protocols": "0.14.0",
158
+ "@dxos/react-client": "0.14.0",
159
+ "@dxos/react-ui": "0.14.0",
160
+ "@dxos/react-ui-attention": "0.14.0",
161
+ "@dxos/react-ui-components": "0.14.0",
162
+ "@dxos/react-ui-editor": "0.14.0",
163
+ "@dxos/react-ui-form": "0.14.0",
164
+ "@dxos/react-ui-list": "0.14.0",
165
+ "@dxos/react-ui-markdown": "0.14.0",
166
+ "@dxos/react-ui-menu": "0.14.0",
167
+ "@dxos/react-ui-masonry": "0.14.0",
168
+ "@dxos/react-ui-query": "0.14.0",
169
+ "@dxos/types": "0.14.0",
170
+ "@dxos/ui-editor": "0.14.0",
171
+ "@dxos/ui-types": "0.14.0",
172
+ "@dxos/util": "0.14.0"
173
173
  },
174
174
  "devDependencies": {
175
175
  "@effect/atom-react": "4.0.0",
@@ -179,11 +179,11 @@
179
179
  "react": "~19.2.7",
180
180
  "react-dom": "~19.2.7",
181
181
  "vite": "^8.2.2",
182
- "@dxos/echo-client": "0.13.0",
183
- "@dxos/plugin-client": "0.13.0",
184
- "@dxos/plugin-testing": "0.13.0",
185
- "@dxos/random": "0.13.0",
186
- "@dxos/react-ui-editor": "0.13.0"
182
+ "@dxos/echo-client": "0.14.0",
183
+ "@dxos/plugin-client": "0.14.0",
184
+ "@dxos/plugin-testing": "0.14.0",
185
+ "@dxos/random": "0.14.0",
186
+ "@dxos/react-ui-editor": "0.14.0"
187
187
  },
188
188
  "peerDependencies": {
189
189
  "@effect/atom-react": "4.0.0",
@@ -67,7 +67,7 @@ export const ArtifactPill = ({ artifact, children }: ArtifactPillProps) => {
67
67
  size='sm'
68
68
  classNames='bg-input-surface text-fg font-normal ring-inset ring ring-neutral-border hover:bg-hover-surface hover:ring-info-border align-baseline'
69
69
  icon={artifactIcon[artifact.kind]}
70
- iconClassNames={artifact.kind === 'video' ? 'text-violet-500' : 'text-sky-500'}
70
+ iconClassNames={artifact.kind === 'video' ? 'text-violet-text' : 'text-sky-text'}
71
71
  label={label}
72
72
  data-testid='pull-request.artifact.pill'
73
73
  />
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { formatDuration } from './CheckRunList.tsx';
8
+
9
+ describe('formatDuration', () => {
10
+ const startedAt = '2026-10-08T10:00:00Z';
11
+
12
+ test('measures a finished run from start to completion', ({ expect }) => {
13
+ expect(formatDuration(startedAt, '2026-10-08T10:04:12Z')).toBe('4m 12s');
14
+ expect(formatDuration(startedAt, '2026-10-08T10:00:42Z')).toBe('42s');
15
+ });
16
+
17
+ test('measures a running run up to now', ({ expect }) => {
18
+ expect(formatDuration(startedAt, undefined, Date.parse('2026-10-08T10:12:05Z'))).toBe('12m 5s');
19
+ });
20
+
21
+ test('prefers completion over now', ({ expect }) => {
22
+ expect(formatDuration(startedAt, '2026-10-08T10:01:00Z', Date.parse('2026-10-08T11:00:00Z'))).toBe('1m 0s');
23
+ });
24
+
25
+ test('is undefined without a start, or without an end of either kind', ({ expect }) => {
26
+ expect(formatDuration(undefined, '2026-10-08T10:01:00Z')).toBeUndefined();
27
+ expect(formatDuration(startedAt)).toBeUndefined();
28
+ });
29
+ });
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import React, { useMemo } from 'react';
5
+ import React, { useEffect, useMemo, useState } from 'react';
6
6
 
7
7
  import { Listbox } from '@dxos/react-ui-list';
8
8
  import * as Hooks from '@dxos/react-ui/Hooks';
@@ -22,12 +22,16 @@ const outcomeIcon: Record<GitHubOperation.CheckOutcome, { icon: string; classNam
22
22
  // What needs attention first: a failure is the reason to open the list, a running check the next.
23
23
  const outcomeOrder: GitHubOperation.CheckOutcome[] = ['failure', 'pending', 'success', 'neutral', 'skipped'];
24
24
 
25
- /** `4m 12s`, or undefined for a run that has not both started and finished. */
26
- export const formatDuration = (startedAt?: string, completedAt?: string): string | undefined => {
27
- if (!startedAt || !completedAt) {
25
+ /**
26
+ * `4m 12s` from start to finish, or to `now` for a run still in progress;
27
+ * undefined for a run that has not started, or has not finished when no `now` is given.
28
+ */
29
+ export const formatDuration = (startedAt?: string, completedAt?: string, now?: number): string | undefined => {
30
+ const end = completedAt ? Date.parse(completedAt) : now;
31
+ if (!startedAt || end === undefined) {
28
32
  return undefined;
29
33
  }
30
- const seconds = Math.max(0, Math.round((Date.parse(completedAt) - Date.parse(startedAt)) / 1000));
34
+ const seconds = Math.max(0, Math.round((end - Date.parse(startedAt)) / 1000));
31
35
  if (Number.isNaN(seconds)) {
32
36
  return undefined;
33
37
  }
@@ -35,6 +39,20 @@ export const formatDuration = (startedAt?: string, completedAt?: string): string
35
39
  return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
36
40
  };
37
41
 
42
+ /** The current time, re-rendering once a second while `active` so elapsed times stay current. */
43
+ const useNow = (active: boolean): number => {
44
+ const [now, setNow] = useState(() => Date.now());
45
+ useEffect(() => {
46
+ if (!active) {
47
+ return;
48
+ }
49
+ setNow(Date.now());
50
+ const interval = setInterval(() => setNow(Date.now()), 1_000);
51
+ return () => clearInterval(interval);
52
+ }, [active]);
53
+ return now;
54
+ };
55
+
38
56
  /** Sorts runs by what needs attention, then by name so shards read in order. */
39
57
  export const sortCheckRuns = (runs: readonly GitHubOperation.CheckRun[]): GitHubOperation.CheckRun[] =>
40
58
  [...runs].sort(
@@ -53,6 +71,7 @@ export const CheckRunList = ({ runs }: CheckRunListProps) => {
53
71
  const { t } = Hooks.useTranslation(meta.profile.key);
54
72
  const sorted = useMemo(() => (runs ? sortCheckRuns(runs) : undefined), [runs]);
55
73
  const summary = useCheckSummary(runs);
74
+ const now = useNow(runs?.some((run) => run.outcome === 'pending' && run.startedAt) ?? false);
56
75
 
57
76
  if (!sorted || sorted.length === 0) {
58
77
  return <Status.Empty>{t(sorted ? 'no-checks.message' : 'checks-loading.message')}</Status.Empty>;
@@ -63,7 +82,7 @@ export const CheckRunList = ({ runs }: CheckRunListProps) => {
63
82
  <Listbox.Content aria-label={summary} data-testid='pull-request.checks'>
64
83
  {sorted.map((run) => {
65
84
  const { icon, classNames } = outcomeIcon[run.outcome];
66
- const duration = formatDuration(run.startedAt, run.completedAt);
85
+ const duration = formatDuration(run.startedAt, run.completedAt, run.outcome === 'pending' ? now : undefined);
67
86
  // GitHub's own word when it says more than the outcome: `timed out` rather than `failed`.
68
87
  const detail =
69
88
  run.conclusion && run.conclusion !== run.outcome && run.conclusion !== 'failure'
@@ -72,7 +91,9 @@ export const CheckRunList = ({ runs }: CheckRunListProps) => {
72
91
  const outcome =
73
92
  run.outcome === 'skipped'
74
93
  ? t('check-outcome.skipped.label')
75
- : (duration ?? t(`check-outcome.${run.outcome}.label`));
94
+ : run.outcome === 'pending'
95
+ ? [t('check-outcome.pending.label'), duration].filter(Boolean).join(' · ')
96
+ : (duration ?? t(`check-outcome.${run.outcome}.label`));
76
97
  const url = run.url;
77
98
  return (
78
99
  <Listbox.Item
@@ -5,6 +5,7 @@
5
5
  import React from 'react';
6
6
 
7
7
  import * as Hooks from '@dxos/react-ui/Hooks';
8
+ import * as Layout from '@dxos/react-ui/Layout';
8
9
  import { type PullRequest } from '@dxos/types';
9
10
 
10
11
  import { meta } from '#meta';
@@ -43,16 +44,18 @@ export type PullRequestStatusProps = {
43
44
  export const PullRequestStatus = ({ reference, title, state, review, ci }: PullRequestStatusProps) => {
44
45
  const { t } = Hooks.useTranslation(meta.profile.key);
45
46
  return (
46
- <div
47
+ <Layout.Flex
48
+ align='center'
49
+ gap='sm'
47
50
  role='status'
48
- className='flex items-center gap-2 px-3 py-1.5 border-b border-separator-subtle text-sm min-w-0'
51
+ classNames='px-3 py-1.5 border-b border-separator-subtle text-sm min-w-0'
49
52
  data-testid='pull-request.status'
50
53
  >
51
54
  <span className='dx-tag dx-tag-inline shrink-0' data-hue='neutral'>
52
55
  {reference}
53
56
  </span>
54
57
  {title && <span className='truncate grow'>{title}</span>}
55
- <div className='flex items-center gap-2 shrink-0 ml-auto'>
58
+ <Layout.Flex align='center' gap='sm' classNames='shrink-0 ml-auto'>
56
59
  {state && (
57
60
  <span className='dx-tag dx-tag-inline' data-hue={stateHue[state]} data-testid='pull-request.status.state'>
58
61
  {t(`pull-request-state.${state}.label`)}
@@ -75,7 +78,7 @@ export const PullRequestStatus = ({ reference, title, state, review, ci }: PullR
75
78
  {ci ? t(`ci-status.${ci.state}.label`) : t('ci-status.unknown.label')}
76
79
  {ci && ci.checks.total > 0 && ` ${ci.checks.passed}/${ci.checks.total}`}
77
80
  </span>
78
- </div>
79
- </div>
81
+ </Layout.Flex>
82
+ </Layout.Flex>
80
83
  );
81
84
  };
@@ -8,6 +8,7 @@ import * as Layer from 'effect/Layer';
8
8
 
9
9
  import * as Operation from '@dxos/compute/Operation';
10
10
  import { Database, Filter, Ref } from '@dxos/echo';
11
+ import { log } from '@dxos/log';
11
12
  import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
12
13
  import { PullRequest } from '@dxos/types';
13
14
 
@@ -18,6 +19,7 @@ import { type PullRequestReference, parsePullRequestReference } from '../github-
18
19
  import { toPullRequestProps } from '../pull-request.ts';
19
20
  import { GitHubApi } from '../services/index.ts';
20
21
  import { githubToken } from './pull-request.ts';
22
+ import { ensureRefreshTrigger } from './refresh.ts';
21
23
 
22
24
  /**
23
25
  * Statuses that may report the token's reach rather than the repository's absence, and so are worth
@@ -133,6 +135,12 @@ const handler: Operation.WithHandler<typeof GitHubOperation.ImportPullRequest> =
133
135
  return yield* Effect.die(new GitHubPullRequestReferenceError({ context: { reference } }));
134
136
  }
135
137
 
138
+ // The first pull request a space holds is what makes refreshing them worth a schedule.
139
+ yield* ensureRefreshTrigger().pipe(
140
+ // The import already succeeded; a missing schedule is recovered the next time a pull request opens.
141
+ Effect.catchDefect((defect) => Effect.sync(() => log.warn('refresh trigger setup failed', { defect }))),
142
+ );
143
+
136
144
  return { pullRequest: Ref.make(object), imported: true };
137
145
  }, Effect.provide(FetchHttpClient.layer)),
138
146
  ),
@@ -25,5 +25,6 @@ export const GitHubOperationHandlerSet = OperationHandlerSet.lazy([
25
25
  ),
26
26
  GitHubOperation.GetPullRequestStatus.pipe(Operation.lazyHandler(() => import('./get-pull-request-status.ts'))),
27
27
  GitHubOperation.SyncPullRequest.pipe(Operation.lazyHandler(() => import('./sync-pull-request.ts'))),
28
+ GitHubOperation.RefreshPullRequests.pipe(Operation.lazyHandler(() => import('./refresh-pull-requests.ts'))),
28
29
  GitHubOperation.GetPullRequestDiff.pipe(Operation.lazyHandler(() => import('./get-pull-request-diff.ts'))),
29
30
  ]);
@@ -0,0 +1,69 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Clock from 'effect/Clock';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
8
+
9
+ import * as Operation from '@dxos/compute/Operation';
10
+ import { log } from '@dxos/log';
11
+
12
+ import { GitHubOperation } from '#types';
13
+
14
+ import { githubToken } from './pull-request.ts';
15
+ import { ensureRefreshTrigger, queryInFlightPullRequests, refreshPullRequest, selectRefreshBatch } from './refresh.ts';
16
+
17
+ /** Bounded so a space with many open pull requests does not burst GitHub's secondary rate limit. */
18
+ const REFRESH_CONCURRENCY = 2;
19
+
20
+ /** The hourly `REFRESH_CRON` period, so each scheduled run lands in its own batch window. */
21
+ const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
22
+
23
+ const handler: Operation.WithHandler<typeof GitHubOperation.RefreshPullRequests> =
24
+ GitHubOperation.RefreshPullRequests.pipe(
25
+ Operation.withHandler(
26
+ Effect.fn(function* () {
27
+ // Collapses any duplicate a racing peer created, so the space keeps one schedule.
28
+ yield* ensureRefreshTrigger();
29
+ const inFlight = yield* queryInFlightPullRequests();
30
+ if (inFlight.length === 0) {
31
+ return { checked: 0, updated: 0, failed: 0 };
32
+ }
33
+
34
+ const window = Math.floor((yield* Clock.currentTimeMillis) / REFRESH_INTERVAL_MS);
35
+ const pullRequests = selectRefreshBatch(inFlight, window);
36
+
37
+ const token = yield* githubToken();
38
+ const results = yield* Effect.forEach(
39
+ pullRequests,
40
+ (pullRequest) =>
41
+ // One deleted or unreachable pull request must not fail the run: EDGE disables a trigger
42
+ // after repeated failures, which would stop refreshing every other one.
43
+ refreshPullRequest(pullRequest, token).pipe(
44
+ Effect.map((updated) => (updated.length > 0 ? 'updated' : 'unchanged')),
45
+ Effect.catchDefect((cause) =>
46
+ Effect.sync(() => {
47
+ log.warn('pull request refresh failed', {
48
+ owner: pullRequest.owner,
49
+ repo: pullRequest.repo,
50
+ number: pullRequest.number,
51
+ cause,
52
+ });
53
+ return 'failed' as const;
54
+ }),
55
+ ),
56
+ ),
57
+ { concurrency: REFRESH_CONCURRENCY },
58
+ );
59
+
60
+ return {
61
+ checked: pullRequests.length,
62
+ updated: results.filter((result) => result === 'updated').length,
63
+ failed: results.filter((result) => result === 'failed').length,
64
+ };
65
+ }, Effect.provide(FetchHttpClient.layer)),
66
+ ),
67
+ );
68
+
69
+ export default handler;
@@ -0,0 +1,112 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import { afterEach, beforeEach, describe, test } from 'vitest';
7
+
8
+ import * as Trigger from '@dxos/compute/Trigger';
9
+ import { Database, Filter, Obj, Ref } from '@dxos/echo';
10
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
11
+ import * as EffectEx from '@dxos/effect/EffectEx';
12
+ import { PullRequest } from '@dxos/types';
13
+
14
+ import { GitHubOperation } from '#types';
15
+
16
+ import { REFRESH_CRON, ensureRefreshTrigger, queryInFlightPullRequests, selectRefreshBatch } from './refresh.ts';
17
+
18
+ describe('pull request refresh', () => {
19
+ let builder: EchoTestBuilder;
20
+
21
+ beforeEach(async () => {
22
+ builder = await new EchoTestBuilder().open();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await builder.close();
27
+ });
28
+
29
+ const setup = async () => {
30
+ const { db, graph } = await builder.createDatabase();
31
+ graph.registry.add([Trigger.Trigger, PullRequest.PullRequest]);
32
+ return db;
33
+ };
34
+
35
+ const run = <T>(db: Database.Database, effect: Effect.Effect<T, never, Database.Service>) =>
36
+ effect.pipe(Effect.provide(Database.layer(db)), EffectEx.runAndForwardErrors);
37
+
38
+ test('a space gets one remote timer trigger, however often it is ensured', async ({ expect }) => {
39
+ const db = await setup();
40
+ const first = await run(db, ensureRefreshTrigger());
41
+ const second = await run(db, ensureRefreshTrigger());
42
+
43
+ expect(second.id).toBe(first.id);
44
+ const triggers = await db.query(Filter.type(Trigger.Trigger)).run();
45
+ expect(triggers).toHaveLength(1);
46
+ expect(first.remote).toBe(true);
47
+ expect(first.enabled).toBe(true);
48
+ expect(first.spec).toEqual({ kind: 'timer', cron: REFRESH_CRON });
49
+ expect(first.runnable?.uri).toBe(GitHubOperation.RefreshPullRequests.meta.key.toString());
50
+ });
51
+
52
+ test('a disabled refresh trigger is left disabled', async ({ expect }) => {
53
+ const db = await setup();
54
+ const trigger = await run(db, ensureRefreshTrigger());
55
+ Obj.update(trigger, (trigger) => {
56
+ trigger.enabled = false;
57
+ });
58
+
59
+ const ensured = await run(db, ensureRefreshTrigger());
60
+ expect(ensured.id).toBe(trigger.id);
61
+ expect(ensured.enabled).toBe(false);
62
+ });
63
+
64
+ test('only pull requests GitHub can still change are refreshed', async ({ expect }) => {
65
+ const db = await setup();
66
+ const make = (number: number, state: PullRequest.State) =>
67
+ db.add(PullRequest.make({ owner: 'dxos', repo: 'dxos', number, title: `#${number}`, state }));
68
+ make(1, 'open');
69
+ make(2, 'draft');
70
+ make(3, 'closed');
71
+ make(4, 'merged');
72
+
73
+ const inFlight = await run(db, queryInFlightPullRequests());
74
+ expect(inFlight.map((pullRequest) => pullRequest.number).sort()).toEqual([1, 2]);
75
+ });
76
+
77
+ test('duplicates a racing peer created collapse to the lowest-id trigger', async ({ expect }) => {
78
+ const db = await setup();
79
+ const make = () =>
80
+ db.add(
81
+ Trigger.make({
82
+ enabled: true,
83
+ remote: true,
84
+ spec: Trigger.specTimer(REFRESH_CRON),
85
+ runnable: Ref.fromURI(GitHubOperation.RefreshPullRequests.meta.key),
86
+ input: {},
87
+ }),
88
+ );
89
+ const ids = [make().id, make().id].sort();
90
+
91
+ const kept = await run(db, ensureRefreshTrigger());
92
+ expect(kept.id).toBe(ids[0]);
93
+ const triggers = await db.query(Filter.type(Trigger.Trigger)).run();
94
+ expect(triggers.map((trigger) => trigger.id)).toEqual([ids[0]]);
95
+ });
96
+
97
+ test('successive runs walk every open pull request in bounded batches', async ({ expect }) => {
98
+ const db = await setup();
99
+ const pullRequests = Array.from({ length: 5 }, (_, index) =>
100
+ db.add(PullRequest.make({ owner: 'dxos', repo: 'dxos', number: index, title: `#${index}`, state: 'open' })),
101
+ );
102
+
103
+ expect(selectRefreshBatch(pullRequests, 0, 10)).toHaveLength(5);
104
+ const seen = new Set<string>();
105
+ for (let window = 0; window < 3; window++) {
106
+ const batch = selectRefreshBatch(pullRequests, window, 2);
107
+ expect(batch).toHaveLength(2);
108
+ batch.forEach((pullRequest) => seen.add(pullRequest.id));
109
+ }
110
+ expect(seen.size).toBe(5);
111
+ });
112
+ });
@@ -0,0 +1,111 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import * as Trigger from '@dxos/compute/Trigger';
8
+ import { Database, Filter, Obj, Ref } from '@dxos/echo';
9
+ import { PullRequest } from '@dxos/types';
10
+
11
+ import { GitHubOperation } from '#types';
12
+
13
+ import { pullRequestChanges } from '../pull-request.ts';
14
+ import { fetchPullRequestWithFallback } from './import-pull-request.ts';
15
+
16
+ /**
17
+ * How often the space's refresh trigger polls its open pull requests: hourly, at an off-the-hour minute
18
+ * so the fleet of spaces does not land on EDGE's busiest tick, and because every open pull request
19
+ * costs a GitHub request against the connection's rate limit.
20
+ */
21
+ export const REFRESH_CRON = '17 * * * *';
22
+
23
+ /** States GitHub can still move a pull request out of; a closed or merged one is settled. */
24
+ const IN_FLIGHT_STATES: readonly PullRequest.State[] = ['open', 'draft'];
25
+
26
+ /**
27
+ * Re-read one stored pull request from GitHub and write back the fields that changed.
28
+ *
29
+ * Uses the anonymous fallback, so a public pull request still refreshes behind a revoked token.
30
+ * Returns the names of the changed fields.
31
+ */
32
+ export const refreshPullRequest = Effect.fn('refreshPullRequest')(function* (
33
+ pullRequest: PullRequest.PullRequest,
34
+ token: string,
35
+ ) {
36
+ const pull = yield* fetchPullRequestWithFallback(pullRequest, token);
37
+ const changes = pullRequestChanges(pullRequest, pull);
38
+ const updated = Object.keys(changes);
39
+ if (updated.length > 0) {
40
+ Obj.update(pullRequest, (pullRequest) => {
41
+ Object.assign(pullRequest, changes);
42
+ });
43
+ }
44
+
45
+ return updated;
46
+ });
47
+
48
+ /** Code-unit order, so every peer and EDGE agree on it regardless of locale. */
49
+ const byId = (left: { id: string }, right: { id: string }): number =>
50
+ left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
51
+
52
+ /** Most pull requests one run refreshes, so a run stays within GitHub's rate limit and EDGE's subrequest budget. */
53
+ export const REFRESH_BATCH_SIZE = 20;
54
+
55
+ /**
56
+ * The slice of `pullRequests` the run in window `window` refreshes: consecutive windows walk the list in
57
+ * id order and wrap, so every pull request is refreshed once every `ceil(count / size)` runs without
58
+ * storing a cursor.
59
+ */
60
+ export const selectRefreshBatch = (
61
+ pullRequests: readonly PullRequest.PullRequest[],
62
+ window: number,
63
+ size = REFRESH_BATCH_SIZE,
64
+ ): PullRequest.PullRequest[] => {
65
+ if (pullRequests.length <= size) {
66
+ return [...pullRequests];
67
+ }
68
+ const ordered = [...pullRequests].sort(byId);
69
+ const start = (window * size) % ordered.length;
70
+ return Array.from({ length: size }, (_, index) => ordered[(start + index) % ordered.length]);
71
+ };
72
+
73
+ /** The space's pull requests GitHub may still change. */
74
+ export const queryInFlightPullRequests = () =>
75
+ Database.query(Filter.type(PullRequest.PullRequest)).run.pipe(
76
+ Effect.map((pullRequests) => pullRequests.filter((pullRequest) => IN_FLIGHT_STATES.includes(pullRequest.state))),
77
+ Effect.orDie,
78
+ );
79
+
80
+ const isRefreshTrigger = (trigger: Trigger.Trigger): boolean =>
81
+ trigger.runnable?.uri === GitHubOperation.RefreshPullRequests.meta.key.toString();
82
+
83
+ /**
84
+ * Give the space its one pull-request refresh trigger, unless it has one already — including one the
85
+ * user disabled, which stays disabled.
86
+ *
87
+ * Peers that race to create it each add their own, so every caller converges on the same keeper (the
88
+ * lowest id) and removes the rest. The trigger runs on EDGE, so pull requests keep refreshing while no
89
+ * client has the space open.
90
+ */
91
+ export const ensureRefreshTrigger = Effect.fn('ensureRefreshTrigger')(function* () {
92
+ const triggers = yield* Database.query(Filter.type(Trigger.Trigger)).run.pipe(Effect.orDie);
93
+ const [keeper, ...duplicates] = triggers.filter(isRefreshTrigger).sort(byId);
94
+ for (const duplicate of duplicates) {
95
+ yield* Database.remove(duplicate);
96
+ }
97
+ if (keeper) {
98
+ return keeper;
99
+ }
100
+
101
+ return yield* Database.add(
102
+ Trigger.make({
103
+ enabled: true,
104
+ remote: true,
105
+ spec: Trigger.specTimer(REFRESH_CRON),
106
+ // Statically defined in the registry, so referred to by key rather than copied into the space.
107
+ runnable: Ref.fromURI(GitHubOperation.RefreshPullRequests.meta.key),
108
+ input: {},
109
+ }),
110
+ );
111
+ });
@@ -6,29 +6,26 @@ import * as Effect from 'effect/Effect';
6
6
  import * as FetchHttpClient from 'effect/http/FetchHttpClient';
7
7
 
8
8
  import * as Operation from '@dxos/compute/Operation';
9
- import { Database, Obj } from '@dxos/echo';
9
+ import { Database } from '@dxos/echo';
10
+ import { log } from '@dxos/log';
10
11
 
11
12
  import { GitHubOperation } from '#types';
12
13
 
13
- import { pullRequestChanges } from '../pull-request.ts';
14
- import { fetchPullRequestWithFallback } from './import-pull-request.ts';
15
14
  import { githubToken, resolvePullRequest } from './pull-request.ts';
15
+ import { ensureRefreshTrigger, refreshPullRequest } from './refresh.ts';
16
16
 
17
17
  const handler: Operation.WithHandler<typeof GitHubOperation.SyncPullRequest> = GitHubOperation.SyncPullRequest.pipe(
18
18
  Operation.withHandler(
19
19
  Effect.fn(function* ({ pullRequest: ref }) {
20
20
  const { pullRequest, db } = yield* resolvePullRequest(ref);
21
21
  const token = yield* githubToken().pipe(Effect.provide(Database.layer(db)));
22
- // With the anonymous fallback, so a public pull request still refreshes behind a revoked token.
23
- const pull = yield* fetchPullRequestWithFallback(pullRequest, token);
24
- const changes = pullRequestChanges(pullRequest, pull);
25
- const updated = Object.keys(changes);
26
- if (updated.length > 0) {
27
- Obj.update(pullRequest, (pullRequest) => {
28
- Object.assign(pullRequest, changes);
29
- });
30
- }
31
-
22
+ const updated = yield* refreshPullRequest(pullRequest, token);
23
+ // Spaces that imported pull requests before the scheduled refresh existed get it on next open.
24
+ yield* ensureRefreshTrigger().pipe(
25
+ Effect.provide(Database.layer(db)),
26
+ // The refresh already succeeded; the schedule is retried on the next open.
27
+ Effect.catchDefect((defect) => Effect.sync(() => log.warn('refresh trigger setup failed', { defect }))),
28
+ );
32
29
  return { updated };
33
30
  }, Effect.provide(FetchHttpClient.layer)),
34
31
  ),
@@ -83,7 +83,7 @@ export const FailingChecks: Story = {
83
83
  ...PULL_REQUEST_13348_RUNS.slice(2),
84
84
  { ...PULL_REQUEST_13348_RUNS[0], name: 'Check / test (shard=6)', outcome: 'failure', conclusion: 'failure' },
85
85
  { ...PULL_REQUEST_13348_RUNS[1], name: 'Check / e2e (shard=7)', outcome: 'failure', conclusion: 'timed_out' },
86
- { name: 'Check / storybook', outcome: 'pending' },
86
+ { name: 'Check / storybook', outcome: 'pending', startedAt: new Date(Date.now() - 252_000).toISOString() },
87
87
  ],
88
88
  },
89
89
  play: async ({ canvasElement }) => {
@@ -92,6 +92,7 @@ export const FailingChecks: Story = {
92
92
  await expect(first).toHaveAttribute('data-outcome', 'failure');
93
93
  await expect(second).toHaveAttribute('data-outcome', 'failure');
94
94
  await expect(third).toHaveAttribute('data-outcome', 'pending');
95
+ await expect(third).toHaveTextContent(/Running · \d+m \d+s/);
95
96
  },
96
97
  };
97
98