@shivaedev/pr-watch 0.0.0 → 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +72 -1
- package/dist/GitHub.d.ts +42 -0
- package/dist/GitHub.d.ts.map +1 -0
- package/dist/GitHub.js +97 -0
- package/dist/GitHub.js.map +1 -0
- package/dist/GitHubHttp.d.ts +12 -0
- package/dist/GitHubHttp.d.ts.map +1 -0
- package/dist/GitHubHttp.js +51 -0
- package/dist/GitHubHttp.js.map +1 -0
- package/dist/StateFile.d.ts +43 -0
- package/dist/StateFile.d.ts.map +1 -0
- package/dist/StateFile.js +33 -0
- package/dist/StateFile.js.map +1 -0
- package/dist/Watcher.d.ts +61 -0
- package/dist/Watcher.d.ts.map +1 -0
- package/dist/Watcher.js +82 -0
- package/dist/Watcher.js.map +1 -0
- package/dist/arguments.d.ts +21 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +84 -0
- package/dist/arguments.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +34 -0
- package/dist/cli.js.map +1 -0
- package/dist/confirmQueue.d.ts +28 -0
- package/dist/confirmQueue.d.ts.map +1 -0
- package/dist/confirmQueue.js +39 -0
- package/dist/confirmQueue.js.map +1 -0
- package/dist/events.d.ts +3 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +32 -0
- package/dist/events.js.map +1 -0
- package/dist/httpResponse.d.ts +15 -0
- package/dist/httpResponse.d.ts.map +1 -0
- package/dist/httpResponse.js +31 -0
- package/dist/httpResponse.js.map +1 -0
- package/dist/model.d.ts +46 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +23 -0
- package/dist/model.js.map +1 -0
- package/dist/program.d.ts +4 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +30 -0
- package/dist/program.js.map +1 -0
- package/dist/quota.d.ts +10 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +45 -0
- package/dist/quota.js.map +1 -0
- package/docs/north-star.md +7 -0
- package/docs/roadmap.md +18 -0
- package/package.json +70 -4
- package/src/GitHub.ts +102 -0
- package/src/GitHubHttp.ts +55 -0
- package/src/StateFile.ts +38 -0
- package/src/Watcher.ts +92 -0
- package/src/arguments.ts +83 -0
- package/src/cli.ts +48 -0
- package/src/confirmQueue.ts +52 -0
- package/src/events.ts +33 -0
- package/src/httpResponse.ts +37 -0
- package/src/model.ts +32 -0
- package/src/program.ts +34 -0
- package/src/quota.ts +52 -0
package/package.json
CHANGED
|
@@ -1,14 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/pr-watch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One REST watcher for pull request transitions across a repository",
|
|
5
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/pr-watch#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
8
|
+
},
|
|
6
9
|
"repository": {
|
|
7
10
|
"type": "git",
|
|
8
11
|
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
9
12
|
"directory": "packages/pr-watch"
|
|
10
13
|
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"type": "module",
|
|
17
|
+
"imports": {
|
|
18
|
+
"#test/*": "./src/test-support/*",
|
|
19
|
+
"#*.ts": {
|
|
20
|
+
"source": "./src/*.ts",
|
|
21
|
+
"types": "./dist/*.d.ts",
|
|
22
|
+
"default": "./dist/*.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
"./*.ts": {
|
|
27
|
+
"types": "./dist/*.d.ts",
|
|
28
|
+
"source": "./src/*.ts",
|
|
29
|
+
"import": "./dist/*.js",
|
|
30
|
+
"default": "./dist/*.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"pr-watch": "./dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src",
|
|
40
|
+
"!src/**/*.test.ts",
|
|
41
|
+
"!src/**/*.spec.ts",
|
|
42
|
+
"!src/**/test-support",
|
|
43
|
+
"docs",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"README.md"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@shivaedev/effect-service": "0.3.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@effect/platform-node": "4.0.4",
|
|
52
|
+
"@effect/platform-node-shared": "4.0.4",
|
|
53
|
+
"@effect/vitest": "4.0.4",
|
|
54
|
+
"@shivaedev/effect-test": "0.4.0",
|
|
55
|
+
"@shivaedev/quality": "0.20.0",
|
|
56
|
+
"@types/node": "24.19.1",
|
|
57
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
58
|
+
"effect": "4.0.4",
|
|
59
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
60
|
+
"vitest": "5.0.3"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@effect/platform-node": "4.0.4",
|
|
64
|
+
"@effect/platform-node-shared": "4.0.4",
|
|
65
|
+
"effect": "4.0.4"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=24"
|
|
69
|
+
},
|
|
11
70
|
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
71
|
+
"access": "public",
|
|
72
|
+
"provenance": true
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
|
|
76
|
+
"ready": "pnpm typecheck && pnpm test && pnpm build",
|
|
77
|
+
"test": "vitest run",
|
|
78
|
+
"typecheck": "tsc --noEmit"
|
|
13
79
|
}
|
|
14
80
|
}
|
package/src/GitHub.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { defineService } from "@shivaedev/effect-service/define-service.ts";
|
|
3
|
+
import { confirmQueue } from "./confirmQueue.ts";
|
|
4
|
+
import { GitHubHttp } from "./GitHubHttp.ts";
|
|
5
|
+
import { type Options, type Snapshot, WatchError } from "./model.ts";
|
|
6
|
+
|
|
7
|
+
const Pull = Schema.Struct({
|
|
8
|
+
autoMerge: Schema.NullOr(Schema.Struct({ mergeMethod: Schema.String }).pipe(Schema.encodeKeys({ mergeMethod: "merge_method" }))),
|
|
9
|
+
head: Schema.Struct({ ref: Schema.String, sha: Schema.String }),
|
|
10
|
+
mergeable: Schema.NullOr(Schema.Boolean),
|
|
11
|
+
mergeableState: Schema.String,
|
|
12
|
+
merged: Schema.Boolean,
|
|
13
|
+
number: Schema.Int,
|
|
14
|
+
state: Schema.Literals(["open", "closed"]),
|
|
15
|
+
title: Schema.String,
|
|
16
|
+
}).pipe(Schema.encodeKeys({ autoMerge: "auto_merge", mergeableState: "mergeable_state" }));
|
|
17
|
+
const Ref = Schema.Struct({ ref: Schema.String });
|
|
18
|
+
const ListedPull = Schema.Struct({ number: Schema.Int });
|
|
19
|
+
const Check = Schema.Struct({ conclusion: Schema.NullOr(Schema.String), status: Schema.String });
|
|
20
|
+
const Checks = Schema.Struct({ checkRuns: Schema.Array(Check) }).pipe(Schema.encodeKeys({ checkRuns: "check_runs" }));
|
|
21
|
+
const Status = Schema.Struct({ state: Schema.String });
|
|
22
|
+
const failures = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure", "stale"]);
|
|
23
|
+
export const GitHub = defineService({
|
|
24
|
+
id: "@shivaedev/pr-watch/GitHub",
|
|
25
|
+
initialize: GitHubHttp,
|
|
26
|
+
methods: (http) => {
|
|
27
|
+
function decode<TSchema extends Schema.Top>(schema: TSchema, body: unknown) {
|
|
28
|
+
return Schema.decodeUnknownEffect(schema)(body).pipe(Effect.mapError(() => new WatchError({ message: "Invalid GitHub response" })));
|
|
29
|
+
}
|
|
30
|
+
function pages(options: Options, path: string) {
|
|
31
|
+
return Effect.gen(function* () {
|
|
32
|
+
const bodies: unknown[] = [];
|
|
33
|
+
const visited = new Set<string>();
|
|
34
|
+
let url: string | undefined = `${options.apiUrl}/repos/${options.repository}${path}`;
|
|
35
|
+
while (url !== undefined) {
|
|
36
|
+
if (visited.has(url)) {
|
|
37
|
+
return yield* Effect.fail(new WatchError({ message: "Repeated pagination URL" }));
|
|
38
|
+
}
|
|
39
|
+
visited.add(url);
|
|
40
|
+
const response: Effect.Success<ReturnType<typeof http.get>> = yield* http.get(url, options.token);
|
|
41
|
+
bodies.push(response.body);
|
|
42
|
+
url = response.next;
|
|
43
|
+
}
|
|
44
|
+
return bodies;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
confirmQueue: (options: Options, snapshot: Snapshot) => confirmQueue(http, options, snapshot),
|
|
49
|
+
delay: http.delay,
|
|
50
|
+
discover: (options: Options) =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const bodies = yield* pages(options, "/pulls?state=open&per_page=100");
|
|
53
|
+
const lists = yield* Effect.forEach(bodies, (body) => decode(Schema.Array(ListedPull), body));
|
|
54
|
+
return lists.flat().map((pr) => pr.number);
|
|
55
|
+
}),
|
|
56
|
+
queue: (options: Options) =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const bodies = yield* pages(options, "/git/matching-refs/heads/gh-readonly-queue/");
|
|
59
|
+
const lists = yield* Effect.forEach(bodies, (body) => decode(Schema.Array(Ref), body));
|
|
60
|
+
return new Set(
|
|
61
|
+
lists.flat().flatMap(({ ref }) => {
|
|
62
|
+
const number = ref.match(/\/pr-(?<number>\d+)-/u)?.groups?.number;
|
|
63
|
+
return number === undefined ? [] : [Number(number)];
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}),
|
|
67
|
+
snapshot: (options: Options, number: number, queued: ReadonlySet<number>) =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const response = yield* http.get(`${options.apiUrl}/repos/${options.repository}/pulls/${number}`, options.token);
|
|
70
|
+
const pr = yield* decode(Pull, response.body);
|
|
71
|
+
if (pr.number !== number) {
|
|
72
|
+
return yield* Effect.fail(new WatchError({ message: "GitHub returned the wrong PR" }));
|
|
73
|
+
}
|
|
74
|
+
if (pr.state === "open" && (pr.mergeable === null || pr.mergeableState === "unknown")) {
|
|
75
|
+
return yield* Effect.fail(new WatchError({ message: `Mergeability pending for PR #${number}` }));
|
|
76
|
+
}
|
|
77
|
+
let failed = false;
|
|
78
|
+
if (pr.state === "open") {
|
|
79
|
+
const bodies = yield* pages(options, `/commits/${pr.head.sha}/check-runs?per_page=100&filter=latest`);
|
|
80
|
+
const checks = yield* Effect.forEach(bodies, (body) => decode(Checks, body));
|
|
81
|
+
const statusResponse = yield* http.get(`${options.apiUrl}/repos/${options.repository}/commits/${pr.head.sha}/status`, options.token);
|
|
82
|
+
const status = yield* decode(Status, statusResponse.body);
|
|
83
|
+
failed =
|
|
84
|
+
checks.some((page) => page.checkRuns.some((check) => check.status === "completed" && failures.has(check.conclusion ?? "")))
|
|
85
|
+
|| ["failure", "error"].includes(status.state);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
autoMerge: pr.autoMerge !== null,
|
|
89
|
+
branch: pr.head.ref,
|
|
90
|
+
dirty: pr.mergeableState === "dirty",
|
|
91
|
+
failed,
|
|
92
|
+
merged: pr.merged,
|
|
93
|
+
number,
|
|
94
|
+
queued: queued.has(number),
|
|
95
|
+
state: pr.state,
|
|
96
|
+
title: pr.title,
|
|
97
|
+
} satisfies Snapshot;
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
requires: [GitHubHttp],
|
|
102
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Clock, Effect } from "effect";
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
import { defineService } from "@shivaedev/effect-service/define-service.ts";
|
|
4
|
+
import { type CachedResponse, decodeResponse, fetchResponse } from "./httpResponse.ts";
|
|
5
|
+
import { WatchError } from "./model.ts";
|
|
6
|
+
import { type Budget, observeBudget, pollDelay } from "./quota.ts";
|
|
7
|
+
export const GitHubHttp = defineService({
|
|
8
|
+
id: "@shivaedev/pr-watch/GitHubHttp",
|
|
9
|
+
initialize: Effect.gen(function* () {
|
|
10
|
+
return {
|
|
11
|
+
budget: { blockedUntil: 0, limit: 5000, remaining: 5000, reset: 0 } satisfies Budget,
|
|
12
|
+
cache: new Map<string, CachedResponse>(),
|
|
13
|
+
client: yield* HttpClient.HttpClient,
|
|
14
|
+
requests: 0,
|
|
15
|
+
};
|
|
16
|
+
}),
|
|
17
|
+
methods: (state) => {
|
|
18
|
+
function request(url: string, token: string, body?: unknown) {
|
|
19
|
+
return Effect.gen(function* () {
|
|
20
|
+
const now = yield* Clock.currentTimeMillis;
|
|
21
|
+
if (state.budget.blockedUntil > now) {
|
|
22
|
+
return yield* Effect.fail(new WatchError({ message: "GitHub quota cooldown" }));
|
|
23
|
+
}
|
|
24
|
+
const cached = body === undefined ? state.cache.get(url) : undefined;
|
|
25
|
+
state.requests += 1;
|
|
26
|
+
const response = yield* fetchResponse(state.client, url, token, cached, body);
|
|
27
|
+
observeBudget(state.budget, response, yield* Clock.currentTimeMillis);
|
|
28
|
+
if (response.status === 304 && cached !== undefined) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
const result = yield* decodeResponse(url, response);
|
|
32
|
+
if (body === undefined) {
|
|
33
|
+
state.cache.set(url, result);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}).pipe(
|
|
37
|
+
Effect.scoped,
|
|
38
|
+
Effect.timeout("30 seconds"),
|
|
39
|
+
Effect.mapError((cause) => (cause instanceof WatchError ? cause : new WatchError({ message: cause.message }))),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
delay: (interval: number) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const now = yield* Clock.currentTimeMillis;
|
|
46
|
+
const delay = pollDelay(state.budget, interval, Math.max(1, state.requests), now);
|
|
47
|
+
state.requests = 0;
|
|
48
|
+
return delay;
|
|
49
|
+
}),
|
|
50
|
+
get: (url: string, token: string) => request(url, token),
|
|
51
|
+
post: (url: string, token: string, body: unknown) => request(url, token, body),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
requires: [HttpClient.HttpClient],
|
|
55
|
+
});
|
package/src/StateFile.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
4
|
+
import { defineService } from "@shivaedev/effect-service/define-service.ts";
|
|
5
|
+
import { State, WatchError } from "./model.ts";
|
|
6
|
+
|
|
7
|
+
function failure(cause: unknown) {
|
|
8
|
+
return new WatchError({ message: cause instanceof Error ? cause.message : "State file operation failed" });
|
|
9
|
+
}
|
|
10
|
+
export const StateFile = defineService({
|
|
11
|
+
id: "@shivaedev/pr-watch/StateFile",
|
|
12
|
+
initialize: FileSystem.FileSystem,
|
|
13
|
+
methods: (fs) => ({
|
|
14
|
+
load: (path: string, repository: string) =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const text = yield* fs.readFileString(path).pipe(
|
|
17
|
+
Effect.catchTag("PlatformError", (error) => (error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error))),
|
|
18
|
+
Effect.mapError(failure),
|
|
19
|
+
);
|
|
20
|
+
if (text === undefined) {
|
|
21
|
+
return { prs: {}, repository, version: 1 } satisfies State;
|
|
22
|
+
}
|
|
23
|
+
const state = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(State))(text).pipe(Effect.mapError(failure));
|
|
24
|
+
if (state.repository !== repository) {
|
|
25
|
+
return yield* Effect.fail(new WatchError({ message: "State file belongs to a different repository" }));
|
|
26
|
+
}
|
|
27
|
+
return state;
|
|
28
|
+
}),
|
|
29
|
+
save: (path: string, state: State) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
yield* fs.makeDirectory(dirname(path), { recursive: true });
|
|
32
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
33
|
+
yield* fs.writeFileString(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
34
|
+
yield* fs.rename(temporary, path);
|
|
35
|
+
}).pipe(Effect.mapError(failure)),
|
|
36
|
+
}),
|
|
37
|
+
requires: [FileSystem.FileSystem],
|
|
38
|
+
});
|
package/src/Watcher.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { defineService } from "@shivaedev/effect-service/define-service.ts";
|
|
3
|
+
import { events } from "./events.ts";
|
|
4
|
+
import { GitHub } from "./GitHub.ts";
|
|
5
|
+
import type { Options, Snapshot, State, WatchError } from "./model.ts";
|
|
6
|
+
import { StateFile } from "./StateFile.ts";
|
|
7
|
+
|
|
8
|
+
function activeNumbers(options: Options, state: State) {
|
|
9
|
+
return [...new Set([...options.numbers, ...(state.openNumbers ?? []), ...Object.keys(state.prs).map(Number)])].filter((number) => {
|
|
10
|
+
const saved = state.prs[String(number)];
|
|
11
|
+
return saved === undefined || (saved.state === "open" && !saved.merged);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export const Watcher = defineService({
|
|
15
|
+
id: "@shivaedev/pr-watch/Watcher",
|
|
16
|
+
initialize: Effect.gen(function* () {
|
|
17
|
+
return { file: yield* StateFile, github: yield* GitHub };
|
|
18
|
+
}),
|
|
19
|
+
methods: ({ file, github }) => {
|
|
20
|
+
function commit(state: State, snapshot: Snapshot, options: Options, emit: (line: string) => Effect.Effect<void, WatchError>) {
|
|
21
|
+
return Effect.uninterruptible(
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const before = state.prs[String(snapshot.number)];
|
|
24
|
+
const baseline =
|
|
25
|
+
before === undefined && snapshot.state === "closed" && state.openNumbers?.includes(snapshot.number)
|
|
26
|
+
? { ...snapshot, merged: false, state: "open" as const }
|
|
27
|
+
: before;
|
|
28
|
+
for (const line of events(baseline, snapshot)) {
|
|
29
|
+
yield* emit(line);
|
|
30
|
+
}
|
|
31
|
+
const next = {
|
|
32
|
+
...state,
|
|
33
|
+
openNumbers: (state.openNumbers ?? []).filter((number) => number !== snapshot.number),
|
|
34
|
+
prs: { ...state.prs, [String(snapshot.number)]: snapshot },
|
|
35
|
+
};
|
|
36
|
+
yield* file.save(options.stateFile, next);
|
|
37
|
+
return next;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
function observe(options: Options, number: number, queue: ReadonlySet<number>, before: Snapshot | undefined) {
|
|
42
|
+
return Effect.gen(function* () {
|
|
43
|
+
const snapshot = yield* github.snapshot(options, number, queue);
|
|
44
|
+
return before?.queued === true && snapshot.state === "open" && !snapshot.queued ? yield* github.confirmQueue(options, snapshot) : snapshot;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function targets(options: Options, state: State) {
|
|
48
|
+
return Effect.gen(function* () {
|
|
49
|
+
const discovered = options.fleet ? yield* github.discover(options) : [];
|
|
50
|
+
const openNumbers = [...new Set([...(state.openNumbers ?? []), ...discovered])].filter((number) => state.prs[String(number)] === undefined);
|
|
51
|
+
const next = openNumbers.length === (state.openNumbers?.length ?? 0) ? state : { ...state, openNumbers };
|
|
52
|
+
if (next !== state) {
|
|
53
|
+
yield* Effect.uninterruptible(file.save(options.stateFile, next));
|
|
54
|
+
}
|
|
55
|
+
const numbers = activeNumbers(options, next);
|
|
56
|
+
const queue = numbers.length > 0 ? yield* github.queue(options) : new Set<number>();
|
|
57
|
+
return { numbers, queue, state: next };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
delay: github.delay,
|
|
62
|
+
load: (options: Options) => file.load(options.stateFile, options.repository),
|
|
63
|
+
poll: (
|
|
64
|
+
options: Options,
|
|
65
|
+
previous: State,
|
|
66
|
+
emit: (line: string) => Effect.Effect<void, WatchError>,
|
|
67
|
+
diagnostic: (message: string) => Effect.Effect<void, WatchError>,
|
|
68
|
+
) =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
let state: State = { ...previous, prs: { ...previous.prs } };
|
|
71
|
+
const targetsForRound = yield* targets(options, state);
|
|
72
|
+
const { numbers, queue } = targetsForRound;
|
|
73
|
+
state = targetsForRound.state;
|
|
74
|
+
let failure: WatchError | undefined;
|
|
75
|
+
for (const number of numbers) {
|
|
76
|
+
const result = yield* Effect.result(observe(options, number, queue, state.prs[String(number)]));
|
|
77
|
+
if (result._tag === "Failure") {
|
|
78
|
+
yield* diagnostic(result.failure.message);
|
|
79
|
+
failure = result.failure;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
state = yield* commit(state, result.success, options, emit);
|
|
83
|
+
}
|
|
84
|
+
if (failure !== undefined) {
|
|
85
|
+
return yield* Effect.fail(failure);
|
|
86
|
+
}
|
|
87
|
+
return state;
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
requires: [GitHub, StateFile],
|
|
92
|
+
});
|
package/src/arguments.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { type Options, WatchError } from "./model.ts";
|
|
4
|
+
export const help = `pr-watch --repo OWNER/REPO [--fleet | PR_NUMBER ...]
|
|
5
|
+
--state PATH Snapshot file (default .pr-watch/OWNER-REPO.json)
|
|
6
|
+
--interval SECONDS Poll interval (default 60; slows as quota falls)
|
|
7
|
+
--once Poll once and exit
|
|
8
|
+
--api-url URL GitHub REST base URL (default https://api.github.com)
|
|
9
|
+
--help Show this help
|
|
10
|
+
Authentication: GH_TOKEN or GITHUB_TOKEN`;
|
|
11
|
+
export function argumentsFor(args: readonly string[], token: string) {
|
|
12
|
+
return Effect.try({
|
|
13
|
+
catch: (cause) => new WatchError({ message: cause instanceof Error ? cause.message : "Invalid arguments" }),
|
|
14
|
+
try: () => {
|
|
15
|
+
const { values, positionals } = parseArgs({
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
args: [...args],
|
|
18
|
+
options: {
|
|
19
|
+
"api-url": { type: "string" },
|
|
20
|
+
fleet: { type: "boolean" },
|
|
21
|
+
help: { type: "boolean" },
|
|
22
|
+
interval: { type: "string" },
|
|
23
|
+
once: { type: "boolean" },
|
|
24
|
+
repo: { type: "string" },
|
|
25
|
+
state: { type: "string" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (values.help) {
|
|
29
|
+
return { help: true as const };
|
|
30
|
+
}
|
|
31
|
+
const repository = values.repo ?? "";
|
|
32
|
+
if (repository.match(/^[\w.-]+\/[\w.-]+$/u) === null) {
|
|
33
|
+
throw new Error("--repo OWNER/REPO is required");
|
|
34
|
+
}
|
|
35
|
+
const numbers = numbersFor(positionals, values.fleet ?? false);
|
|
36
|
+
const interval = intervalFor(values.interval);
|
|
37
|
+
const apiUrl = apiUrlFor(values["api-url"]);
|
|
38
|
+
return {
|
|
39
|
+
help: false as const,
|
|
40
|
+
once: values.once ?? false,
|
|
41
|
+
options: {
|
|
42
|
+
apiUrl,
|
|
43
|
+
fleet: values.fleet ?? false,
|
|
44
|
+
interval,
|
|
45
|
+
numbers,
|
|
46
|
+
repository,
|
|
47
|
+
stateFile: values.state ?? `.pr-watch/${repository.replace("/", "-")}.json`,
|
|
48
|
+
token,
|
|
49
|
+
} satisfies Options,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function numbersFor(positionals: readonly string[], fleet: boolean): readonly number[] {
|
|
55
|
+
if (positionals.some((value) => value.match(/^[1-9]\d*$/u) === null)) {
|
|
56
|
+
throw new Error("PR numbers must be positive integers");
|
|
57
|
+
}
|
|
58
|
+
const numbers = [...new Set(positionals.map(Number))];
|
|
59
|
+
if (numbers.some((number) => !Number.isSafeInteger(number))) {
|
|
60
|
+
throw new Error("Invalid PR number");
|
|
61
|
+
}
|
|
62
|
+
if (fleet ? numbers.length > 0 : numbers.length === 0) {
|
|
63
|
+
throw new Error("Choose --fleet or one or more PR numbers");
|
|
64
|
+
}
|
|
65
|
+
return numbers;
|
|
66
|
+
}
|
|
67
|
+
function intervalFor(value: string | undefined): number {
|
|
68
|
+
const interval = Number(value ?? 60) * 1000;
|
|
69
|
+
if (!Number.isFinite(interval) || interval < 1000) {
|
|
70
|
+
throw new Error("--interval must be at least one second");
|
|
71
|
+
}
|
|
72
|
+
return interval;
|
|
73
|
+
}
|
|
74
|
+
function apiUrlFor(value: string | undefined): string {
|
|
75
|
+
const apiUrl = (value ?? "https://api.github.com").replace(/\/$/u, "");
|
|
76
|
+
const url = new URL(apiUrl);
|
|
77
|
+
const secure = url.protocol === "https:";
|
|
78
|
+
const loopback = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
79
|
+
if (!(secure || loopback) || url.username || url.password || url.search || url.hash) {
|
|
80
|
+
throw new Error("--api-url requires HTTPS (or loopback HTTP)");
|
|
81
|
+
}
|
|
82
|
+
return apiUrl;
|
|
83
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { NodeFileSystem } from "@effect/platform-node";
|
|
4
|
+
import { Config, Effect, Layer } from "effect";
|
|
5
|
+
import { FetchHttpClient } from "effect/http";
|
|
6
|
+
import { GitHub } from "./GitHub.ts";
|
|
7
|
+
import { GitHubHttp } from "./GitHubHttp.ts";
|
|
8
|
+
import { WatchError } from "./model.ts";
|
|
9
|
+
import { program } from "./program.ts";
|
|
10
|
+
import { StateFile } from "./StateFile.ts";
|
|
11
|
+
import { Watcher } from "./Watcher.ts";
|
|
12
|
+
|
|
13
|
+
const services = Watcher.layer.pipe(
|
|
14
|
+
Layer.provide(Layer.mergeAll(StateFile.layer, GitHub.layer.pipe(Layer.provide(GitHubHttp.layer)))),
|
|
15
|
+
Layer.provide(
|
|
16
|
+
Layer.mergeAll(
|
|
17
|
+
NodeFileSystem.layer,
|
|
18
|
+
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ redirect: "error" }))),
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
function write(stream: NodeJS.WriteStream) {
|
|
23
|
+
return (line: string) =>
|
|
24
|
+
Effect.tryPromise({
|
|
25
|
+
catch: () => new WatchError({ message: "Output stream failed" }),
|
|
26
|
+
try: () => new Promise<void>((resolve, reject) => stream.write(`${line}\n`, (error) => (error ? reject(error) : resolve()))),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const abort = new AbortController();
|
|
30
|
+
process.once("SIGINT", () => abort.abort());
|
|
31
|
+
process.once("SIGTERM", () => abort.abort());
|
|
32
|
+
try {
|
|
33
|
+
await Effect.runPromise(
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const token = yield* Config.String("GH_TOKEN").pipe(
|
|
36
|
+
Config.orElse(() => Config.String("GITHUB_TOKEN")),
|
|
37
|
+
Config.withDefault(""),
|
|
38
|
+
);
|
|
39
|
+
yield* program(process.argv.slice(2), token, write(process.stdout), write(process.stderr));
|
|
40
|
+
}).pipe(Effect.provide(services)),
|
|
41
|
+
{ signal: abort.signal },
|
|
42
|
+
);
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
if (!abort.signal.aborted) {
|
|
45
|
+
process.stderr.write(`${cause instanceof Error ? cause.message : "Watcher failed"}\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Context, Effect, Schema } from "effect";
|
|
2
|
+
import type { GitHubHttp } from "./GitHubHttp.ts";
|
|
3
|
+
import { type Options, type Snapshot, WatchError } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
const QueueResponse = Schema.Struct({
|
|
6
|
+
data: Schema.NullOr(
|
|
7
|
+
Schema.Struct({
|
|
8
|
+
repository: Schema.NullOr(
|
|
9
|
+
Schema.Struct({
|
|
10
|
+
pullRequest: Schema.NullOr(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
autoMergeRequest: Schema.NullOr(Schema.Struct({ enabledAt: Schema.String })),
|
|
13
|
+
mergeQueueEntry: Schema.NullOr(Schema.Struct({ id: Schema.String })),
|
|
14
|
+
state: Schema.Literals(["OPEN", "CLOSED", "MERGED"]),
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
errors: Schema.optionalKey(Schema.Array(Schema.Unknown)),
|
|
22
|
+
});
|
|
23
|
+
const query = `query QueueEntry($owner: String!, $name: String!, $number: Int!) {
|
|
24
|
+
repository(owner: $owner, name: $name) {
|
|
25
|
+
pullRequest(number: $number) {
|
|
26
|
+
state
|
|
27
|
+
autoMergeRequest { enabledAt }
|
|
28
|
+
mergeQueueEntry { id }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}`;
|
|
32
|
+
export const confirmQueue = Effect.fn("GitHub.confirmQueue")(function* (
|
|
33
|
+
http: Context.Service.Shape<typeof GitHubHttp>,
|
|
34
|
+
options: Options,
|
|
35
|
+
snapshot: Snapshot,
|
|
36
|
+
) {
|
|
37
|
+
const url = new URL(options.apiUrl);
|
|
38
|
+
url.pathname = url.pathname.replace(/\/api\/v3\/?$/u, "/api/graphql");
|
|
39
|
+
if (!url.pathname.endsWith("/api/graphql")) {
|
|
40
|
+
url.pathname = `${url.pathname.replace(/\/$/u, "")}/graphql`;
|
|
41
|
+
}
|
|
42
|
+
const [owner, name] = options.repository.split("/");
|
|
43
|
+
const response = yield* http.post(url.href, options.token, { query, variables: { name, number: snapshot.number, owner } });
|
|
44
|
+
const body = yield* Schema.decodeUnknownEffect(QueueResponse)(response.body).pipe(
|
|
45
|
+
Effect.mapError(() => new WatchError({ message: "Invalid GitHub queue confirmation" })),
|
|
46
|
+
);
|
|
47
|
+
const pr = body.data?.repository?.pullRequest;
|
|
48
|
+
if ((body.errors?.length ?? 0) > 0 || pr === undefined || pr === null || pr.state !== "OPEN") {
|
|
49
|
+
return yield* Effect.fail(new WatchError({ message: `Queue confirmation unavailable for PR #${snapshot.number}` }));
|
|
50
|
+
}
|
|
51
|
+
return { ...snapshot, autoMerge: pr.autoMergeRequest !== null, queued: pr.mergeQueueEntry !== null || pr.autoMergeRequest !== null };
|
|
52
|
+
});
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Snapshot } from "./model.ts";
|
|
2
|
+
|
|
3
|
+
function stuck(pr: Snapshot): boolean {
|
|
4
|
+
return pr.state === "open" && pr.autoMerge && (pr.dirty || pr.failed);
|
|
5
|
+
}
|
|
6
|
+
function openEvents(before: Snapshot, after: Snapshot): readonly string[] {
|
|
7
|
+
const transitions: readonly (readonly [boolean, string])[] = [
|
|
8
|
+
[!before.dirty && after.dirty, "conflict"],
|
|
9
|
+
[!before.queued && after.queued, "queued"],
|
|
10
|
+
[before.queued && !after.queued, "dequeued"],
|
|
11
|
+
[(before.autoMerge || before.queued) && !after.autoMerge && !after.queued, "auto-merge-off"],
|
|
12
|
+
[!before.failed && after.failed, "checks-failed"],
|
|
13
|
+
[!stuck(before) && stuck(after), "stuck"],
|
|
14
|
+
];
|
|
15
|
+
return transitions.flatMap(([changed, event]) => (changed ? [event] : []));
|
|
16
|
+
}
|
|
17
|
+
export function events(before: Snapshot | undefined, after: Snapshot): readonly string[] {
|
|
18
|
+
if (before === undefined && !stuck(after)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const baseline = before ?? { ...after, autoMerge: false, dirty: false, failed: false };
|
|
22
|
+
const found: string[] = [];
|
|
23
|
+
if (!baseline.merged && after.merged) {
|
|
24
|
+
found.push("merged");
|
|
25
|
+
}
|
|
26
|
+
if (baseline.state === "open" && after.state === "closed" && !after.merged) {
|
|
27
|
+
found.push("closed");
|
|
28
|
+
}
|
|
29
|
+
if (after.state === "open") {
|
|
30
|
+
found.push(...openEvents(baseline, after));
|
|
31
|
+
}
|
|
32
|
+
return found.map((event) => JSON.stringify({ branch: after.branch, event, number: after.number, title: after.title }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { WatchError } from "./model.ts";
|
|
4
|
+
export interface CachedResponse {
|
|
5
|
+
readonly body: unknown;
|
|
6
|
+
readonly etag: string | null;
|
|
7
|
+
readonly next: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
export function fetchResponse(client: HttpClient.HttpClient, url: string, token: string, cached: CachedResponse | undefined, body?: unknown) {
|
|
10
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" };
|
|
11
|
+
if (token) {
|
|
12
|
+
headers.Authorization = `Bearer ${token}`;
|
|
13
|
+
}
|
|
14
|
+
if (cached?.etag) {
|
|
15
|
+
headers["If-None-Match"] = cached.etag;
|
|
16
|
+
}
|
|
17
|
+
return body === undefined
|
|
18
|
+
? client.get(url, { headers })
|
|
19
|
+
: HttpClientRequest.post(url, { headers }).pipe(HttpClientRequest.bodyJson(body), Effect.flatMap(client.execute));
|
|
20
|
+
}
|
|
21
|
+
export function decodeResponse(url: string, response: HttpClientResponse.HttpClientResponse) {
|
|
22
|
+
return Effect.gen(function* () {
|
|
23
|
+
if (response.status < 200 || response.status >= 300) {
|
|
24
|
+
return yield* Effect.fail(new WatchError({ message: `GitHub HTTP ${response.status} for ${new URL(url).pathname}` }));
|
|
25
|
+
}
|
|
26
|
+
const body = yield* response.json;
|
|
27
|
+
const next = response.headers.link?.match(/<(?<url>[^>]+)>; rel="next"/u)?.groups?.url;
|
|
28
|
+
yield* Effect.try(() => {
|
|
29
|
+
if (next !== undefined && new URL(next).origin !== new URL(url).origin) {
|
|
30
|
+
throw new Error("Cross-origin pagination refused");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return { body, etag: response.headers.etag ?? null, next } satisfies CachedResponse;
|
|
34
|
+
}).pipe(
|
|
35
|
+
Effect.mapError((cause) => (cause instanceof WatchError ? cause : new WatchError({ message: "Invalid GitHub response or pagination URL" }))),
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export class WatchError extends Schema.TaggedError<WatchError>()("WatchError", {
|
|
3
|
+
message: Schema.String,
|
|
4
|
+
}) {}
|
|
5
|
+
export const Snapshot = Schema.Struct({
|
|
6
|
+
autoMerge: Schema.Boolean,
|
|
7
|
+
branch: Schema.String,
|
|
8
|
+
dirty: Schema.Boolean,
|
|
9
|
+
failed: Schema.Boolean,
|
|
10
|
+
merged: Schema.Boolean,
|
|
11
|
+
number: Schema.Int,
|
|
12
|
+
queued: Schema.Boolean,
|
|
13
|
+
state: Schema.Literals(["open", "closed"]),
|
|
14
|
+
title: Schema.String,
|
|
15
|
+
});
|
|
16
|
+
export type Snapshot = typeof Snapshot.Type;
|
|
17
|
+
export const State = Schema.Struct({
|
|
18
|
+
openNumbers: Schema.optionalKey(Schema.Array(Schema.Int)),
|
|
19
|
+
prs: Schema.Record(Schema.String, Snapshot),
|
|
20
|
+
repository: Schema.String,
|
|
21
|
+
version: Schema.Literal(1),
|
|
22
|
+
});
|
|
23
|
+
export type State = typeof State.Type;
|
|
24
|
+
export interface Options {
|
|
25
|
+
readonly apiUrl: string;
|
|
26
|
+
readonly fleet: boolean;
|
|
27
|
+
readonly interval: number;
|
|
28
|
+
readonly numbers: readonly number[];
|
|
29
|
+
readonly repository: string;
|
|
30
|
+
readonly stateFile: string;
|
|
31
|
+
readonly token: string;
|
|
32
|
+
}
|