@reviewflow/sdk 0.0.0-stage → 6.21.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/LICENSE +15 -0
- package/README.md +86 -2
- package/build/files-node.mjs +70 -0
- package/build/git-node.mjs +1792 -0
- package/build/index-browser.es.js +3338 -0
- package/build/index-node.mjs +3338 -0
- package/package.json +136 -4
- package/src/api/pullRequest.ts +617 -0
- package/src/config/insertCheckoutEntry.ts +54 -0
- package/src/diff/diffPaths.ts +78 -0
- package/src/diff/impacts.ts +47 -0
- package/src/diff/readingOrder.ts +428 -0
- package/src/diff/themes.ts +105 -0
- package/src/diff/types.ts +141 -0
- package/src/files/editableFile.ts +163 -0
- package/src/files.ts +13 -0
- package/src/git/groupDiffPaths.ts +134 -0
- package/src/git/manifests.ts +96 -0
- package/src/git/moduleBindings.ts +571 -0
- package/src/git/pullRequestDiff.ts +159 -0
- package/src/git/readBranchDiff.ts +359 -0
- package/src/git/readImpacts.ts +797 -0
- package/src/git/readingOrder.ts +190 -0
- package/src/git/run.ts +213 -0
- package/src/git.ts +38 -0
- package/src/github/applySuggestions.ts +399 -0
- package/src/github/editPullRequest.ts +790 -0
- package/src/github/fetchCompareDiff.ts +21 -0
- package/src/github/imageProxies.ts +43 -0
- package/src/github/lineDiff.ts +145 -0
- package/src/github/prQuery.ts +298 -0
- package/src/github/pullRequestDetail.ts +1347 -0
- package/src/github/pullRequestHead.ts +65 -0
- package/src/github/pullRequestRevisions.ts +124 -0
- package/src/github/transport.ts +171 -0
- package/src/index.ts +35 -0
- package/src/reviewflow/board.ts +333 -0
- package/src/reviewflow/changedParts.ts +80 -0
- package/src/reviewflow/checksFromReviewflow.ts +75 -0
- package/src/reviewflow/client.ts +589 -0
- package/src/reviewflow/follow.ts +345 -0
- package/src/reviewflow/status.ts +17 -0
- package/src/reviewflow/stream.ts +199 -0
- package/src/types/entities.ts +8 -0
- package/src/types/ids.ts +1 -0
- package/src/types/reviewflow.ts +32 -0
- package/src/utils/pullRequestEdit.ts +108 -0
- package/src/utils/pullRequestPart.ts +12 -0
- package/src/utils/pullRequestReaction.ts +19 -0
- package/src/utils/pullRequestSuggestion.ts +148 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GitHubTransport } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The compare endpoint rather than the pull request's own diff: it takes any
|
|
5
|
+
* range, so a single commit or "since my last review" reads like the whole
|
|
6
|
+
* pull request. Three dots, from the merge base, as a local `git diff` does.
|
|
7
|
+
* GitHub refuses it past 300 files or 20,000 lines.
|
|
8
|
+
*/
|
|
9
|
+
export const fetchCompareDiff = (
|
|
10
|
+
transport: GitHubTransport,
|
|
11
|
+
{ ownerRepo, base, head }: { ownerRepo: string; base: string; head: string },
|
|
12
|
+
): Promise<string> => {
|
|
13
|
+
const [owner, name, ...rest] = ownerRepo.split("/");
|
|
14
|
+
if (owner === undefined || name === undefined || rest.length > 0) {
|
|
15
|
+
throw new Error(`${ownerRepo} is not owner/repo`);
|
|
16
|
+
}
|
|
17
|
+
return transport.text({
|
|
18
|
+
path: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${base}...${head}`,
|
|
19
|
+
accept: "application/vnd.github.diff",
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub serves the images of a body through camo, its image proxy, so the
|
|
3
|
+
* host of an image a stranger put in a pull request never learns who read it
|
|
4
|
+
* nor when. A camo url is signed with GitHub's key: it cannot be built, only
|
|
5
|
+
* read from the html GitHub renders (`bodyHTML`), where each proxied `<img>`
|
|
6
|
+
* keeps the url the markdown wrote in `data-canonical-src`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const imgTag = /<img\b[^>]*>/gi;
|
|
10
|
+
|
|
11
|
+
const decodeAttribute = (value: string): string =>
|
|
12
|
+
value
|
|
13
|
+
.replaceAll(""", '"')
|
|
14
|
+
.replaceAll("'", "'")
|
|
15
|
+
.replaceAll("<", "<")
|
|
16
|
+
.replaceAll(">", ">")
|
|
17
|
+
.replaceAll("&", "&");
|
|
18
|
+
|
|
19
|
+
const attribute = (tag: string, name: string): string | null => {
|
|
20
|
+
const match = new RegExp(`\\s${name}="([^"]*)"`, "i").exec(tag);
|
|
21
|
+
return match?.[1] === undefined ? null : decodeAttribute(match[1]);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The url each image of `html` was written with, to the one GitHub serves it from. */
|
|
25
|
+
export const imageProxiesOf = (
|
|
26
|
+
html: string | null | undefined,
|
|
27
|
+
): Record<string, string> => {
|
|
28
|
+
const proxies: Record<string, string> = {};
|
|
29
|
+
for (const [tag] of (html ?? "").matchAll(imgTag)) {
|
|
30
|
+
const src = attribute(tag, "src");
|
|
31
|
+
const canonical = attribute(tag, "data-canonical-src");
|
|
32
|
+
if (src !== null && canonical !== null && src !== canonical) {
|
|
33
|
+
proxies[canonical] = src;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return proxies;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Every body's proxies of one part, merged. */
|
|
40
|
+
export const mergeImageProxies = (
|
|
41
|
+
htmls: readonly (string | null | undefined)[],
|
|
42
|
+
): Record<string, string> =>
|
|
43
|
+
Object.assign({}, ...htmls.map(imageProxiesOf)) as Record<string, string>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export type DiffLineKind = "added" | "context" | "removed";
|
|
2
|
+
|
|
3
|
+
export interface DiffLine {
|
|
4
|
+
kind: DiffLineKind;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Past this many cells the table is not built at all and the two sides are
|
|
10
|
+
* emitted whole — every old line removed, then every new line added. Still a
|
|
11
|
+
* correct diff, only not a minimal one, and 400×400 is well past anything an
|
|
12
|
+
* `Edit` has measured (153 lines is the largest) once the common prefix and
|
|
13
|
+
* suffix are off. The cap is in cells rather than lines because that is what
|
|
14
|
+
* the allocation is: a 1200-line cap on each side would allow a 5.7MB transient
|
|
15
|
+
* array, several times per page.
|
|
16
|
+
*/
|
|
17
|
+
const maxDiffCells = 160_000;
|
|
18
|
+
|
|
19
|
+
/** A file that ends in a newline is not a file with a blank last line. */
|
|
20
|
+
const toLines = (value: string): string[] => {
|
|
21
|
+
if (value === "") return [];
|
|
22
|
+
const lines = value.split("\n");
|
|
23
|
+
if (lines.at(-1) === "") lines.pop();
|
|
24
|
+
return lines;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const context = (text: string): DiffLine => ({ kind: "context", text });
|
|
28
|
+
const removed = (text: string): DiffLine => ({ kind: "removed", text });
|
|
29
|
+
const added = (text: string): DiffLine => ({ kind: "added", text });
|
|
30
|
+
|
|
31
|
+
const wholeSides = (before: string[], after: string[]): DiffLine[] => [
|
|
32
|
+
...before.map(removed),
|
|
33
|
+
...after.map(added),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The shortest edit script between two runs that share neither their first nor
|
|
38
|
+
* their last line, by the longest common subsequence of them.
|
|
39
|
+
*
|
|
40
|
+
* At a position where a removal and an addition both apply, the removal is
|
|
41
|
+
* emitted first — git's order, and the one that lets the two be read as a
|
|
42
|
+
* replacement of each other.
|
|
43
|
+
*/
|
|
44
|
+
const shortestEdit = (before: string[], after: string[]): DiffLine[] => {
|
|
45
|
+
const height = before.length;
|
|
46
|
+
const width = after.length + 1;
|
|
47
|
+
const common = new Uint32Array((height + 1) * width);
|
|
48
|
+
|
|
49
|
+
for (let row = height - 1; row >= 0; row--) {
|
|
50
|
+
const beforeLine = before[row];
|
|
51
|
+
for (let column = after.length - 1; column >= 0; column--) {
|
|
52
|
+
common[row * width + column] =
|
|
53
|
+
beforeLine === after[column]
|
|
54
|
+
? (common[(row + 1) * width + column + 1] ?? 0) + 1
|
|
55
|
+
: Math.max(
|
|
56
|
+
common[(row + 1) * width + column] ?? 0,
|
|
57
|
+
common[row * width + column + 1] ?? 0,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines: DiffLine[] = [];
|
|
63
|
+
let row = 0;
|
|
64
|
+
let column = 0;
|
|
65
|
+
|
|
66
|
+
while (row < height && column < after.length) {
|
|
67
|
+
if (before[row] === after[column]) {
|
|
68
|
+
lines.push(context(before[row] ?? ""));
|
|
69
|
+
row++;
|
|
70
|
+
column++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
(common[(row + 1) * width + column] ?? 0) >=
|
|
75
|
+
(common[row * width + column + 1] ?? 0)
|
|
76
|
+
) {
|
|
77
|
+
lines.push(removed(before[row] ?? ""));
|
|
78
|
+
row++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
lines.push(added(after[column] ?? ""));
|
|
82
|
+
column++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
...lines,
|
|
87
|
+
...before.slice(row).map(removed),
|
|
88
|
+
...after.slice(column).map(added),
|
|
89
|
+
];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The two sides of a change, line by line.
|
|
94
|
+
*
|
|
95
|
+
* **The common prefix and suffix come off first**, and that is the guard that
|
|
96
|
+
* does the real work rather than the cell cap below it: Claude Code's
|
|
97
|
+
* `old_string` and `new_string` are a fragment and its replacement, and a
|
|
98
|
+
* fragment is chosen to be unique, which means it almost always opens and
|
|
99
|
+
* closes on lines the replacement keeps. Trimming them is O(n+m) and allocates
|
|
100
|
+
* nothing, and it collapses nearly every measured edit to a handful of lines
|
|
101
|
+
* before the table is sized.
|
|
102
|
+
*
|
|
103
|
+
* A whole new file is `before: ""`, which trims to nothing and comes back as
|
|
104
|
+
* every line added.
|
|
105
|
+
*/
|
|
106
|
+
export const diffLines = ({
|
|
107
|
+
before,
|
|
108
|
+
after,
|
|
109
|
+
}: {
|
|
110
|
+
before: string;
|
|
111
|
+
after: string;
|
|
112
|
+
}): DiffLine[] => {
|
|
113
|
+
const beforeLines = toLines(before);
|
|
114
|
+
const afterLines = toLines(after);
|
|
115
|
+
|
|
116
|
+
let head = 0;
|
|
117
|
+
while (
|
|
118
|
+
head < beforeLines.length &&
|
|
119
|
+
head < afterLines.length &&
|
|
120
|
+
beforeLines[head] === afterLines[head]
|
|
121
|
+
) {
|
|
122
|
+
head++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let tail = 0;
|
|
126
|
+
while (
|
|
127
|
+
tail < beforeLines.length - head &&
|
|
128
|
+
tail < afterLines.length - head &&
|
|
129
|
+
beforeLines[beforeLines.length - 1 - tail] ===
|
|
130
|
+
afterLines[afterLines.length - 1 - tail]
|
|
131
|
+
) {
|
|
132
|
+
tail++;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const beforeMiddle = beforeLines.slice(head, beforeLines.length - tail);
|
|
136
|
+
const afterMiddle = afterLines.slice(head, afterLines.length - tail);
|
|
137
|
+
|
|
138
|
+
return [
|
|
139
|
+
...beforeLines.slice(0, head).map(context),
|
|
140
|
+
...(beforeMiddle.length * afterMiddle.length > maxDiffCells
|
|
141
|
+
? wholeSides(beforeMiddle, afterMiddle)
|
|
142
|
+
: shortestEdit(beforeMiddle, afterMiddle)),
|
|
143
|
+
...beforeLines.slice(beforeLines.length - tail).map(context),
|
|
144
|
+
];
|
|
145
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PrMergeable,
|
|
3
|
+
PrReviewDecision,
|
|
4
|
+
PrState,
|
|
5
|
+
} from "../types/entities.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One branch to ask GitHub about — a task's current branch, which is the only
|
|
9
|
+
* branch a PR is wanted for (a task has N branches over time and only the
|
|
10
|
+
* latest one is live).
|
|
11
|
+
*/
|
|
12
|
+
export interface PrQueryTarget {
|
|
13
|
+
branchId: string;
|
|
14
|
+
taskId: string;
|
|
15
|
+
/** `owner/repo`, from `repo.owner_repo` */
|
|
16
|
+
ownerRepo: string;
|
|
17
|
+
branch: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PrQuery {
|
|
21
|
+
query: string;
|
|
22
|
+
/** alias -> the branch it asks about; how an answer is matched back */
|
|
23
|
+
targetsByAlias: Map<string, PrQueryTarget>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every branch is one aliased field in one document, so the whole board costs a
|
|
28
|
+
* single HTTP request and — because every connection is asked for `first: 1` —
|
|
29
|
+
* a single point of the GraphQL rate limit. A query per PR would be one request
|
|
30
|
+
* and one point *each*, which is the thing this exists to avoid.
|
|
31
|
+
*
|
|
32
|
+
* The counts come off `checkRunCountsByState` / `statusContextCountsByState`
|
|
33
|
+
* rather than off the context nodes: those are aggregates GitHub computes, so
|
|
34
|
+
* `contexts(first: 1)` returns exact totals for a PR with two hundred checks
|
|
35
|
+
* without transferring or paying for them.
|
|
36
|
+
*/
|
|
37
|
+
export const prFields = `fragment prFields on PullRequest {
|
|
38
|
+
number
|
|
39
|
+
state
|
|
40
|
+
isDraft
|
|
41
|
+
reviewDecision
|
|
42
|
+
mergedAt
|
|
43
|
+
mergeable
|
|
44
|
+
headRefOid
|
|
45
|
+
baseRefName
|
|
46
|
+
commits(last: 1) {
|
|
47
|
+
nodes {
|
|
48
|
+
commit {
|
|
49
|
+
statusCheckRollup {
|
|
50
|
+
contexts(first: 1) {
|
|
51
|
+
checkRunCountsByState { count state }
|
|
52
|
+
statusContextCountsByState { count state }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `owner/repo` exactly, because that is what the GraphQL arguments are. A value
|
|
62
|
+
* that does not split into two segments is skipped rather than guessed at: it
|
|
63
|
+
* would resolve to a repository that is not the operator's, and registration
|
|
64
|
+
* already refuses a repo with no usable remote.
|
|
65
|
+
*/
|
|
66
|
+
export const splitOwnerRepo = (
|
|
67
|
+
ownerRepo: string,
|
|
68
|
+
): { owner: string; name: string } | null => {
|
|
69
|
+
const segments = ownerRepo.split("/");
|
|
70
|
+
const [owner, name] = segments;
|
|
71
|
+
if (segments.length !== 2 || !owner || !name) return null;
|
|
72
|
+
return { owner, name };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Returns null when no target is queryable, so no request is made at all. */
|
|
76
|
+
export const buildPrQuery = (targets: PrQueryTarget[]): PrQuery | null => {
|
|
77
|
+
const byRepo = new Map<string, { alias: string; branch: string }[]>();
|
|
78
|
+
const targetsByAlias = new Map<string, PrQueryTarget>();
|
|
79
|
+
|
|
80
|
+
targets.forEach((target, index) => {
|
|
81
|
+
if (target.branch === "" || splitOwnerRepo(target.ownerRepo) === null) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const alias = `t${String(index)}`;
|
|
85
|
+
targetsByAlias.set(alias, target);
|
|
86
|
+
const existing = byRepo.get(target.ownerRepo);
|
|
87
|
+
const entry = { alias, branch: target.branch };
|
|
88
|
+
if (existing) existing.push(entry);
|
|
89
|
+
else byRepo.set(target.ownerRepo, [entry]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (targetsByAlias.size === 0) return null;
|
|
93
|
+
|
|
94
|
+
const repoBlocks = [...byRepo].map(([ownerRepo, branches], repoIndex) => {
|
|
95
|
+
const { owner, name } = splitOwnerRepo(ownerRepo) as {
|
|
96
|
+
owner: string;
|
|
97
|
+
name: string;
|
|
98
|
+
};
|
|
99
|
+
const branchFields = branches.map(
|
|
100
|
+
({ alias, branch }) =>
|
|
101
|
+
// Newest first: a branch that had a closed PR and then a new one is
|
|
102
|
+
// described by the new one, and `first: 1` is what keeps the cost flat.
|
|
103
|
+
` ${alias}: pullRequests(headRefName: ${JSON.stringify(branch)}, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ...prFields } }`,
|
|
104
|
+
);
|
|
105
|
+
return [
|
|
106
|
+
` r${String(repoIndex)}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {`,
|
|
107
|
+
...branchFields,
|
|
108
|
+
" }",
|
|
109
|
+
].join("\n");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
query: `query {\n${repoBlocks.join("\n")}\n}\n\n${prFields}`,
|
|
114
|
+
targetsByAlias,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
interface GhCountByState {
|
|
119
|
+
count: number;
|
|
120
|
+
state: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface GhPullRequest {
|
|
124
|
+
number: number;
|
|
125
|
+
state: string;
|
|
126
|
+
isDraft: boolean;
|
|
127
|
+
reviewDecision: string | null;
|
|
128
|
+
mergedAt: string | null;
|
|
129
|
+
mergeable: string;
|
|
130
|
+
headRefOid: string;
|
|
131
|
+
baseRefName: string;
|
|
132
|
+
commits: {
|
|
133
|
+
nodes: {
|
|
134
|
+
commit: {
|
|
135
|
+
statusCheckRollup: {
|
|
136
|
+
contexts: {
|
|
137
|
+
checkRunCountsByState: GhCountByState[] | null;
|
|
138
|
+
statusContextCountsByState: GhCountByState[] | null;
|
|
139
|
+
};
|
|
140
|
+
} | null;
|
|
141
|
+
};
|
|
142
|
+
}[];
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Repository aliases at the top level, branch aliases inside each. A repository
|
|
148
|
+
* the token cannot see comes back as a null alias next to the ones that
|
|
149
|
+
* resolved, which is why this reads defensively rather than asserting a shape.
|
|
150
|
+
*/
|
|
151
|
+
export type PrQueryData = Record<
|
|
152
|
+
string,
|
|
153
|
+
Record<string, { nodes: (GhPullRequest | null)[] | null } | null> | null
|
|
154
|
+
>;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How GitHub's rollup states fold into the three counts `pr` keeps.
|
|
158
|
+
*
|
|
159
|
+
* A whitelist, like the hook mapper: a state GitHub adds later is ignored
|
|
160
|
+
* rather than counted as something, because the one thing worse than an
|
|
161
|
+
* unranked board is a board that calls a red PR green.
|
|
162
|
+
*
|
|
163
|
+
* `CANCELLED` and `STALE` count as pending rather than failed on purpose. They
|
|
164
|
+
* mean "no answer for this commit" — a superseded run after a force-push is the
|
|
165
|
+
* common cause — and calling that a failure would raise `checksFailing` on a
|
|
166
|
+
* PR nobody needs to look at, which is how an attention signal stops being read.
|
|
167
|
+
*/
|
|
168
|
+
export const checkOutcomes = new Map<string, "failed" | "passed" | "pending">([
|
|
169
|
+
["ACTION_REQUIRED", "failed"],
|
|
170
|
+
["ERROR", "failed"],
|
|
171
|
+
["FAILURE", "failed"],
|
|
172
|
+
["STARTUP_FAILURE", "failed"],
|
|
173
|
+
["TIMED_OUT", "failed"],
|
|
174
|
+
|
|
175
|
+
["COMPLETED", "passed"],
|
|
176
|
+
["NEUTRAL", "passed"],
|
|
177
|
+
["SKIPPED", "passed"],
|
|
178
|
+
["SUCCESS", "passed"],
|
|
179
|
+
|
|
180
|
+
["CANCELLED", "pending"],
|
|
181
|
+
["EXPECTED", "pending"],
|
|
182
|
+
["IN_PROGRESS", "pending"],
|
|
183
|
+
["PENDING", "pending"],
|
|
184
|
+
["QUEUED", "pending"],
|
|
185
|
+
["STALE", "pending"],
|
|
186
|
+
["WAITING", "pending"],
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
interface CheckCounts {
|
|
190
|
+
checksPassed: number;
|
|
191
|
+
checksFailed: number;
|
|
192
|
+
checksPending: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const countChecks = (pullRequest: GhPullRequest): CheckCounts => {
|
|
196
|
+
const totals = { failed: 0, passed: 0, pending: 0 };
|
|
197
|
+
const contexts =
|
|
198
|
+
pullRequest.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
199
|
+
|
|
200
|
+
for (const { count, state } of [
|
|
201
|
+
...(contexts?.checkRunCountsByState ?? []),
|
|
202
|
+
...(contexts?.statusContextCountsByState ?? []),
|
|
203
|
+
]) {
|
|
204
|
+
const outcome = checkOutcomes.get(state);
|
|
205
|
+
if (outcome !== undefined) totals[outcome] += count;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
checksPassed: totals.passed,
|
|
210
|
+
checksFailed: totals.failed,
|
|
211
|
+
checksPending: totals.pending,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** `isDraft` is a separate field upstream; `pr.state` folds it in. */
|
|
216
|
+
const prStates = new Map<string, PrState>([
|
|
217
|
+
["CLOSED", "closed"],
|
|
218
|
+
["MERGED", "merged"],
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
const toPrState = (state: string, isDraft: boolean): PrState | null => {
|
|
222
|
+
if (state === "OPEN") return isDraft ? "draft" : "open";
|
|
223
|
+
return prStates.get(state) ?? null;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const reviewDecisions = new Map<string, PrReviewDecision>([
|
|
227
|
+
["APPROVED", "approved"],
|
|
228
|
+
["CHANGES_REQUESTED", "changesRequested"],
|
|
229
|
+
["REVIEW_REQUIRED", "reviewRequired"],
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
const mergeables = new Map<string, PrMergeable>([
|
|
233
|
+
["CONFLICTING", "conflicting"],
|
|
234
|
+
["MERGEABLE", "mergeable"],
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
export interface FetchedPr extends CheckCounts {
|
|
238
|
+
target: PrQueryTarget;
|
|
239
|
+
number: number;
|
|
240
|
+
state: PrState;
|
|
241
|
+
reviewDecision: PrReviewDecision;
|
|
242
|
+
mergeable: PrMergeable;
|
|
243
|
+
mergedAt: string | null;
|
|
244
|
+
/** always set by GitHub; null only from a reviewflow document that lacks it */
|
|
245
|
+
headSha: string | null;
|
|
246
|
+
/** null only from a reviewflow server that predates `baseRef` */
|
|
247
|
+
baseRef: string | null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Null for a state GitHub added after this was written. */
|
|
251
|
+
export const toFetchedPr = (
|
|
252
|
+
target: PrQueryTarget,
|
|
253
|
+
pullRequest: GhPullRequest,
|
|
254
|
+
): FetchedPr | null => {
|
|
255
|
+
const state = toPrState(pullRequest.state, pullRequest.isDraft);
|
|
256
|
+
if (state === null) return null;
|
|
257
|
+
return {
|
|
258
|
+
target,
|
|
259
|
+
number: pullRequest.number,
|
|
260
|
+
state,
|
|
261
|
+
reviewDecision:
|
|
262
|
+
pullRequest.reviewDecision === null
|
|
263
|
+
? null
|
|
264
|
+
: (reviewDecisions.get(pullRequest.reviewDecision) ?? null),
|
|
265
|
+
mergeable: mergeables.get(pullRequest.mergeable) ?? "unknown",
|
|
266
|
+
mergedAt: pullRequest.mergedAt,
|
|
267
|
+
headSha: pullRequest.headRefOid,
|
|
268
|
+
baseRef: pullRequest.baseRefName,
|
|
269
|
+
...countChecks(pullRequest),
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Only the aliases that both resolved and carry a pull request produce a row. A
|
|
275
|
+
* branch with no PR is not an absence to record — it is the normal state of a
|
|
276
|
+
* worktree whose work has not been pushed for review yet.
|
|
277
|
+
*/
|
|
278
|
+
export const parsePrQueryResponse = (
|
|
279
|
+
data: PrQueryData | null,
|
|
280
|
+
targetsByAlias: Map<string, PrQueryTarget>,
|
|
281
|
+
): FetchedPr[] => {
|
|
282
|
+
if (data === null) return [];
|
|
283
|
+
const found: FetchedPr[] = [];
|
|
284
|
+
|
|
285
|
+
for (const repository of Object.values(data)) {
|
|
286
|
+
if (!repository) continue;
|
|
287
|
+
for (const [alias, connection] of Object.entries(repository)) {
|
|
288
|
+
const target = targetsByAlias.get(alias);
|
|
289
|
+
const pullRequest = connection?.nodes?.[0];
|
|
290
|
+
if (!target || !pullRequest) continue;
|
|
291
|
+
|
|
292
|
+
const fetched = toFetchedPr(target, pullRequest);
|
|
293
|
+
if (fetched !== null) found.push(fetched);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return found;
|
|
298
|
+
};
|