@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.
- package/dist/lib/GitHubOperation.mjs +27 -1
- package/dist/lib/GitHubOperation.mjs.map +1 -1
- package/dist/lib/chunk-PullRequestArticle.mjs +31 -11
- package/dist/lib/chunk-PullRequestArticle.mjs.map +1 -1
- package/dist/lib/chunk-import-pull-request.mjs +78 -3
- package/dist/lib/chunk-import-pull-request.mjs.map +1 -1
- package/dist/lib/chunk-refresh-pull-requests.mjs +49 -0
- package/dist/lib/chunk-refresh-pull-requests.mjs.map +1 -0
- package/dist/lib/chunk-sync-pull-request.mjs +11 -9
- package/dist/lib/chunk-sync-pull-request.mjs.map +1 -1
- package/dist/lib/operations.mjs +2 -1
- package/dist/lib/operations.mjs.map +1 -1
- package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts +5 -2
- package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts.map +1 -1
- package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts +2 -0
- package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts.map +1 -1
- package/dist/types/src/operations/import-pull-request.d.ts.map +1 -1
- package/dist/types/src/operations/index.d.ts.map +1 -1
- package/dist/types/src/operations/refresh-pull-requests.d.ts +5 -0
- package/dist/types/src/operations/refresh-pull-requests.d.ts.map +1 -0
- package/dist/types/src/operations/refresh.d.ts +37 -0
- package/dist/types/src/operations/refresh.d.ts.map +1 -0
- package/dist/types/src/operations/refresh.test.d.ts +2 -0
- package/dist/types/src/operations/refresh.test.d.ts.map +1 -0
- package/dist/types/src/operations/sync-pull-request.d.ts.map +1 -1
- package/dist/types/src/stories/PullRequestOverview.stories.d.ts.map +1 -1
- package/dist/types/src/types/GitHubOperation.d.ts +11 -0
- package/dist/types/src/types/GitHubOperation.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +42 -42
- package/src/components/PullRequestOverview/ArtifactPill.tsx +1 -1
- package/src/components/PullRequestOverview/CheckRunList.test.ts +29 -0
- package/src/components/PullRequestOverview/CheckRunList.tsx +28 -7
- package/src/components/PullRequestStatus/PullRequestStatus.tsx +8 -5
- package/src/operations/import-pull-request.ts +8 -0
- package/src/operations/index.ts +1 -0
- package/src/operations/refresh-pull-requests.ts +69 -0
- package/src/operations/refresh.test.ts +112 -0
- package/src/operations/refresh.ts +111 -0
- package/src/operations/sync-pull-request.ts +10 -13
- package/src/stories/PullRequestOverview.stories.tsx +2 -1
- 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.
|
|
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.
|
|
138
|
-
"@dxos/app-framework": "0.
|
|
139
|
-
"@dxos/app-graph": "0.
|
|
140
|
-
"@dxos/app-toolkit": "0.
|
|
141
|
-
"@dxos/compute": "0.
|
|
142
|
-
"@dxos/echo": "0.
|
|
143
|
-
"@dxos/echo-query": "0.
|
|
144
|
-
"@dxos/echo-react": "0.
|
|
145
|
-
"@dxos/effect": "0.
|
|
146
|
-
"@dxos/errors": "0.
|
|
147
|
-
"@dxos/graph": "0.
|
|
148
|
-
"@dxos/keys": "0.
|
|
149
|
-
"@dxos/link": "0.
|
|
150
|
-
"@dxos/lit-ui": "0.
|
|
151
|
-
"@dxos/log": "0.
|
|
152
|
-
"@dxos/plugin-connector": "0.
|
|
153
|
-
"@dxos/plugin-crx": "0.
|
|
154
|
-
"@dxos/plugin-markdown": "0.
|
|
155
|
-
"@dxos/plugin-preview": "0.
|
|
156
|
-
"@dxos/plugin-space": "0.
|
|
157
|
-
"@dxos/protocols": "0.
|
|
158
|
-
"@dxos/react-client": "0.
|
|
159
|
-
"@dxos/react-ui": "0.
|
|
160
|
-
"@dxos/react-ui-attention": "0.
|
|
161
|
-
"@dxos/react-ui-components": "0.
|
|
162
|
-
"@dxos/react-ui-editor": "0.
|
|
163
|
-
"@dxos/react-ui-form": "0.
|
|
164
|
-
"@dxos/react-ui-list": "0.
|
|
165
|
-
"@dxos/react-ui-markdown": "0.
|
|
166
|
-
"@dxos/react-ui-
|
|
167
|
-
"@dxos/react-ui-
|
|
168
|
-
"@dxos/react-ui-query": "0.
|
|
169
|
-
"@dxos/types": "0.
|
|
170
|
-
"@dxos/ui-editor": "0.
|
|
171
|
-
"@dxos/ui-types": "0.
|
|
172
|
-
"@dxos/util": "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.
|
|
183
|
-
"@dxos/plugin-client": "0.
|
|
184
|
-
"@dxos/plugin-testing": "0.
|
|
185
|
-
"@dxos/random": "0.
|
|
186
|
-
"@dxos/react-ui-editor": "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-
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
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((
|
|
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
|
-
:
|
|
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
|
-
<
|
|
47
|
+
<Layout.Flex
|
|
48
|
+
align='center'
|
|
49
|
+
gap='sm'
|
|
47
50
|
role='status'
|
|
48
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
79
|
-
</
|
|
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
|
),
|
package/src/operations/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|