pr-shepherd 0.2.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/.claude-plugin/plugin.json +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- package/src/util/path-segment.mts +2 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
query MultiPrStatusPaged($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
|
|
2
|
+
repository(owner: $owner, name: $repo) {
|
|
3
|
+
pullRequest(number: $pr) {
|
|
4
|
+
number
|
|
5
|
+
title
|
|
6
|
+
state
|
|
7
|
+
isDraft
|
|
8
|
+
mergeStateStatus
|
|
9
|
+
reviewDecision
|
|
10
|
+
reviewThreads(last: 100, before: $cursor) {
|
|
11
|
+
totalCount
|
|
12
|
+
pageInfo {
|
|
13
|
+
hasPreviousPage
|
|
14
|
+
startCursor
|
|
15
|
+
}
|
|
16
|
+
nodes {
|
|
17
|
+
isResolved
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
commits(last: 1) {
|
|
21
|
+
nodes {
|
|
22
|
+
commit {
|
|
23
|
+
statusCheckRollup {
|
|
24
|
+
state
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
query MultiPrStatus($owner: String!, $repo: String!, $pr: Int!) {
|
|
2
|
+
repository(owner: $owner, name: $repo) {
|
|
3
|
+
pullRequest(number: $pr) {
|
|
4
|
+
number
|
|
5
|
+
state
|
|
6
|
+
isDraft
|
|
7
|
+
title
|
|
8
|
+
mergeStateStatus
|
|
9
|
+
reviewDecision
|
|
10
|
+
# totalCount lets callers detect truncation; pageInfo enables backward pagination.
|
|
11
|
+
reviewThreads(last: 100) {
|
|
12
|
+
totalCount
|
|
13
|
+
pageInfo {
|
|
14
|
+
hasPreviousPage
|
|
15
|
+
startCursor
|
|
16
|
+
}
|
|
17
|
+
nodes {
|
|
18
|
+
isResolved
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
commits(last: 1) {
|
|
22
|
+
nodes {
|
|
23
|
+
commit {
|
|
24
|
+
statusCheckRollup {
|
|
25
|
+
state
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic GraphQL cursor-based paginator for shepherd.
|
|
3
|
+
*
|
|
4
|
+
* Both paginators accept a `fetchFn` instead of calling `graphql` directly,
|
|
5
|
+
* which makes them testable without any mocking — tests supply a pure function
|
|
6
|
+
* that returns pages of fake data.
|
|
7
|
+
*
|
|
8
|
+
* GitHub's GraphQL connections support two cursor directions:
|
|
9
|
+
* - Forward (`after` + `first`) — used by check contexts.
|
|
10
|
+
* - Backward (`before` + `last`) — used by reviewThreads (default GitHub order).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PageInfo {
|
|
14
|
+
hasNextPage?: boolean;
|
|
15
|
+
hasPreviousPage?: boolean;
|
|
16
|
+
endCursor?: string | null;
|
|
17
|
+
startCursor?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Connection<T> {
|
|
21
|
+
pageInfo: PageInfo;
|
|
22
|
+
nodes: T[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Paginate forward through a GraphQL connection (`first` / `after` cursors).
|
|
27
|
+
*
|
|
28
|
+
* @param fetchFn Called once per page. Receives the cursor (or null for
|
|
29
|
+
* the very first page) and returns a Connection<T>.
|
|
30
|
+
* @param initialCursor Start from this cursor instead of null. Pass the
|
|
31
|
+
* `endCursor` of an already-fetched page to fetch only
|
|
32
|
+
* the pages *after* it, avoiding a duplicate re-fetch.
|
|
33
|
+
*/
|
|
34
|
+
export async function paginateForward<T>(
|
|
35
|
+
fetchFn: (cursor: string | null) => Promise<Connection<T>>,
|
|
36
|
+
initialCursor?: string | null,
|
|
37
|
+
): Promise<T[]> {
|
|
38
|
+
const all: T[] = [];
|
|
39
|
+
let cursor: string | null = initialCursor ?? null;
|
|
40
|
+
|
|
41
|
+
for (;;) {
|
|
42
|
+
// eslint-disable-next-line no-await-in-loop
|
|
43
|
+
const conn = await fetchFn(cursor);
|
|
44
|
+
|
|
45
|
+
all.push(...conn.nodes);
|
|
46
|
+
|
|
47
|
+
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
|
|
48
|
+
cursor = conn.pageInfo.endCursor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return all;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Paginate backward through a GraphQL connection (`last` / `before` cursors).
|
|
56
|
+
*
|
|
57
|
+
* Used for `reviewThreads(last: 100, before: $before)`.
|
|
58
|
+
*
|
|
59
|
+
* @param fetchFn Called once per page. Receives the cursor (or null for
|
|
60
|
+
* the very first page). Returns a Connection<T> with
|
|
61
|
+
* `hasPreviousPage` and `startCursor` in pageInfo.
|
|
62
|
+
* @param initialCursor Start from this cursor instead of null. Pass the
|
|
63
|
+
* `startCursor` of an already-fetched page to avoid
|
|
64
|
+
* re-fetching it (fetch only the pages *before* it).
|
|
65
|
+
*/
|
|
66
|
+
export async function paginateBackward<T>(
|
|
67
|
+
fetchFn: (cursor: string | null) => Promise<Connection<T>>,
|
|
68
|
+
initialCursor?: string | null,
|
|
69
|
+
): Promise<T[]> {
|
|
70
|
+
const all: T[] = [];
|
|
71
|
+
let cursor: string | null = initialCursor ?? null;
|
|
72
|
+
|
|
73
|
+
for (;;) {
|
|
74
|
+
// eslint-disable-next-line no-await-in-loop
|
|
75
|
+
const conn = await fetchFn(cursor);
|
|
76
|
+
|
|
77
|
+
// Backward pagination returns items oldest-first within each page but pages
|
|
78
|
+
// go from newest-to-oldest. Prepend each page so final array is oldest-first.
|
|
79
|
+
all.unshift(...conn.nodes);
|
|
80
|
+
|
|
81
|
+
if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor) break;
|
|
82
|
+
cursor = conn.pageInfo.startCursor;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return all;
|
|
86
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { paginateForward, paginateBackward, type Connection } from "./pagination.mts";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// paginateForward
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("paginateForward", () => {
|
|
9
|
+
it("collects all nodes across three pages", async () => {
|
|
10
|
+
const pages: Connection<string>[] = [
|
|
11
|
+
{ pageInfo: { hasNextPage: true, endCursor: "cursor1" }, nodes: ["a", "b"] },
|
|
12
|
+
{ pageInfo: { hasNextPage: true, endCursor: "cursor2" }, nodes: ["c"] },
|
|
13
|
+
{ pageInfo: { hasNextPage: false, endCursor: null }, nodes: ["d", "e"] },
|
|
14
|
+
];
|
|
15
|
+
const cursors: Array<string | null> = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
|
|
18
|
+
const result = await paginateForward((cursor) => {
|
|
19
|
+
cursors.push(cursor);
|
|
20
|
+
return Promise.resolve(pages[i++]!);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result).toEqual(["a", "b", "c", "d", "e"]);
|
|
24
|
+
expect(cursors).toEqual([null, "cursor1", "cursor2"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("stops after a single page when hasNextPage is false", async () => {
|
|
28
|
+
let calls = 0;
|
|
29
|
+
const result = await paginateForward(() => {
|
|
30
|
+
calls++;
|
|
31
|
+
return Promise.resolve({
|
|
32
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
33
|
+
nodes: ["x", "y"],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual(["x", "y"]);
|
|
38
|
+
expect(calls).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns an empty array for an empty first page", async () => {
|
|
42
|
+
const result = await paginateForward(() =>
|
|
43
|
+
Promise.resolve({
|
|
44
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
45
|
+
nodes: [] as string[],
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("starts from initialCursor to avoid re-fetching the already-known page", async () => {
|
|
53
|
+
// Simulates the batch.mts use case: the initial query already returned page
|
|
54
|
+
// ending at 'cur-first'. paginateForward should start from that endCursor
|
|
55
|
+
// so it only fetches pages *after* it.
|
|
56
|
+
const pages: Record<string, Connection<string>> = {
|
|
57
|
+
"cur-first": {
|
|
58
|
+
pageInfo: { hasNextPage: true, endCursor: "cur-second" },
|
|
59
|
+
nodes: ["c", "d"],
|
|
60
|
+
},
|
|
61
|
+
"cur-second": {
|
|
62
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
63
|
+
nodes: ["e"],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const cursors: Array<string | null> = [];
|
|
67
|
+
|
|
68
|
+
const result = await paginateForward((cursor) => {
|
|
69
|
+
cursors.push(cursor);
|
|
70
|
+
return Promise.resolve(pages[cursor ?? ""]!);
|
|
71
|
+
}, "cur-first");
|
|
72
|
+
|
|
73
|
+
// Should fetch pages after 'cur-first', not re-fetch it.
|
|
74
|
+
expect(cursors).toEqual(["cur-first", "cur-second"]);
|
|
75
|
+
expect(result).toEqual(["c", "d", "e"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// paginateBackward
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe("paginateBackward", () => {
|
|
84
|
+
it("collects nodes across pages and returns oldest-first", async () => {
|
|
85
|
+
// Backward pagination: newest page first, oldest last.
|
|
86
|
+
const pages: Connection<string>[] = [
|
|
87
|
+
{ pageInfo: { hasPreviousPage: true, startCursor: "cur1" }, nodes: ["newer", "newest"] },
|
|
88
|
+
{ pageInfo: { hasPreviousPage: true, startCursor: "cur2" }, nodes: ["older"] },
|
|
89
|
+
{ pageInfo: { hasPreviousPage: false, startCursor: null }, nodes: ["oldest"] },
|
|
90
|
+
];
|
|
91
|
+
const cursors: Array<string | null> = [];
|
|
92
|
+
let i = 0;
|
|
93
|
+
|
|
94
|
+
const result = await paginateBackward((cursor) => {
|
|
95
|
+
cursors.push(cursor);
|
|
96
|
+
return Promise.resolve(pages[i++]!);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// unshift inserts older pages at the front.
|
|
100
|
+
expect(result).toEqual(["oldest", "older", "newer", "newest"]);
|
|
101
|
+
expect(cursors).toEqual([null, "cur1", "cur2"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns single-page nodes unchanged", async () => {
|
|
105
|
+
const result = await paginateBackward(() =>
|
|
106
|
+
Promise.resolve({
|
|
107
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
108
|
+
nodes: ["a", "b"],
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual(["a", "b"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("starts from initialCursor to avoid re-fetching the already-known page", async () => {
|
|
116
|
+
// Simulates the batch.mts use case: the initial query already returned the
|
|
117
|
+
// "newest" page (cur-newest). paginateBackward should start from that
|
|
118
|
+
// startCursor so it only fetches pages *before* it.
|
|
119
|
+
const pages: Record<string, Connection<string>> = {
|
|
120
|
+
"cur-newest": {
|
|
121
|
+
pageInfo: { hasPreviousPage: true, startCursor: "cur-middle" },
|
|
122
|
+
nodes: ["middle"],
|
|
123
|
+
},
|
|
124
|
+
"cur-middle": {
|
|
125
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
126
|
+
nodes: ["oldest"],
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const cursors: Array<string | null> = [];
|
|
130
|
+
|
|
131
|
+
const result = await paginateBackward((cursor) => {
|
|
132
|
+
cursors.push(cursor);
|
|
133
|
+
return Promise.resolve(pages[cursor ?? ""]!);
|
|
134
|
+
}, "cur-newest");
|
|
135
|
+
|
|
136
|
+
// Should fetch pages before 'cur-newest', not re-fetch 'cur-newest' itself.
|
|
137
|
+
expect(cursors).toEqual(["cur-newest", "cur-middle"]);
|
|
138
|
+
expect(result).toEqual(["oldest", "middle"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL query strings used by pr-shepherd.
|
|
3
|
+
*
|
|
4
|
+
* Query strings live in src/github/gql/*.gql.
|
|
5
|
+
* Never inline raw GraphQL strings in .ts source files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const gql = (name: string): string =>
|
|
12
|
+
readFileSync(join((import.meta as { dirname: string }).dirname, "gql", name), "utf8");
|
|
13
|
+
|
|
14
|
+
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
15
|
+
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
16
|
+
|
|
17
|
+
/** Resolve a single review thread. */
|
|
18
|
+
export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
|
|
19
|
+
|
|
20
|
+
/** Minimize a PR comment (IssueComment). */
|
|
21
|
+
export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
|
|
22
|
+
|
|
23
|
+
/** Dismiss a pull request review. */
|
|
24
|
+
export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
|
|
25
|
+
|
|
26
|
+
/** Multi-PR status query for `shepherd status PR1 PR2 …`. */
|
|
27
|
+
export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
|
|
28
|
+
|
|
29
|
+
/** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
|
|
30
|
+
export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
|
package/src/index.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pr-shepherd — unified GitHub PR status + auto-resolve CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* pr-shepherd check [PR]
|
|
7
|
+
* pr-shepherd resolve [PR]
|
|
8
|
+
* pr-shepherd iterate [PR]
|
|
9
|
+
* pr-shepherd status PR1 [PR2 …]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { main } from "./cli.mts";
|
|
13
|
+
|
|
14
|
+
main(process.argv).catch((err) => {
|
|
15
|
+
process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a shepherd `MergeStatusResult` from raw PR data.
|
|
3
|
+
*
|
|
4
|
+
* `pr.state` is passed through unchanged; `iterate` handles the cancel action
|
|
5
|
+
* for non-OPEN (merged/closed) PRs — this function does not branch on it.
|
|
6
|
+
*
|
|
7
|
+
* Interpretation order for `status` — first match wins:
|
|
8
|
+
* 1. mergeable == CONFLICTING → CONFLICTS
|
|
9
|
+
* 2. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts)
|
|
10
|
+
* 3. copilotReviewInProgress → BLOCKED
|
|
11
|
+
* 4. mergeStateStatus BEHIND → BEHIND
|
|
12
|
+
* 5. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
|
|
13
|
+
* 6. mergeStateStatus UNSTABLE → UNSTABLE
|
|
14
|
+
* 7. isDraft → DRAFT
|
|
15
|
+
* 8. mergeStateStatus UNKNOWN → UNKNOWN
|
|
16
|
+
* 9. mergeStateStatus CLEAN → CLEAN
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { BatchPrData, MergeStatusResult } from "../types.mts";
|
|
20
|
+
import { loadConfig } from "../config/load.mts";
|
|
21
|
+
|
|
22
|
+
export function deriveMergeStatus(pr: BatchPrData): MergeStatusResult {
|
|
23
|
+
const copilotReviewInProgress = detectCopilotReview(pr);
|
|
24
|
+
|
|
25
|
+
let status: MergeStatusResult["status"];
|
|
26
|
+
|
|
27
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
28
|
+
status = "CONFLICTS";
|
|
29
|
+
} else if (copilotReviewInProgress) {
|
|
30
|
+
status = "BLOCKED";
|
|
31
|
+
} else if (pr.mergeStateStatus === "DIRTY") {
|
|
32
|
+
// DIRTY means GitHub detected merge conflicts in the branch.
|
|
33
|
+
status = "CONFLICTS";
|
|
34
|
+
} else if (pr.mergeStateStatus === "BEHIND") {
|
|
35
|
+
status = "BEHIND";
|
|
36
|
+
} else if (pr.mergeStateStatus === "BLOCKED" || pr.mergeStateStatus === "HAS_HOOKS") {
|
|
37
|
+
status = "BLOCKED";
|
|
38
|
+
} else if (pr.mergeStateStatus === "UNSTABLE") {
|
|
39
|
+
status = "UNSTABLE";
|
|
40
|
+
} else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
|
|
41
|
+
status = "DRAFT";
|
|
42
|
+
} else if (pr.mergeStateStatus === "UNKNOWN") {
|
|
43
|
+
status = "UNKNOWN";
|
|
44
|
+
} else {
|
|
45
|
+
status = "CLEAN";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
status,
|
|
50
|
+
state: pr.state,
|
|
51
|
+
isDraft: pr.isDraft,
|
|
52
|
+
mergeable: pr.mergeable,
|
|
53
|
+
reviewDecision: pr.reviewDecision,
|
|
54
|
+
copilotReviewInProgress,
|
|
55
|
+
mergeStateStatus: pr.mergeStateStatus,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Copilot review detection
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function detectCopilotReview(pr: BatchPrData): boolean {
|
|
64
|
+
// A blocking bot review is "in progress" when:
|
|
65
|
+
// 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
|
|
66
|
+
// 2. Any latestReview has such a login AND state == "PENDING"
|
|
67
|
+
const prefixes = loadConfig().mergeStatus.blockingReviewerLogins.map((l) => l.toLowerCase());
|
|
68
|
+
const isBlocking = (login: string) => prefixes.some((p) => login.toLowerCase().startsWith(p));
|
|
69
|
+
|
|
70
|
+
const requested = pr.reviewRequests.some((r) => isBlocking(r.login));
|
|
71
|
+
const pendingReview = pr.latestReviews.some((r) => isBlocking(r.login) && r.state === "PENDING");
|
|
72
|
+
|
|
73
|
+
return requested || pendingReview;
|
|
74
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { deriveMergeStatus } from "./derive.mts";
|
|
3
|
+
import type { BatchPrData } from "../types.mts";
|
|
4
|
+
|
|
5
|
+
function makePr(overrides: Partial<BatchPrData>): BatchPrData {
|
|
6
|
+
return {
|
|
7
|
+
number: 42,
|
|
8
|
+
state: "OPEN",
|
|
9
|
+
isDraft: false,
|
|
10
|
+
mergeable: "MERGEABLE",
|
|
11
|
+
mergeStateStatus: "CLEAN",
|
|
12
|
+
reviewDecision: null,
|
|
13
|
+
headRefOid: "abc123",
|
|
14
|
+
reviewRequests: [],
|
|
15
|
+
latestReviews: [],
|
|
16
|
+
reviewThreads: [],
|
|
17
|
+
comments: [],
|
|
18
|
+
changesRequestedReviews: [],
|
|
19
|
+
checks: [],
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Interpretation order (first match wins)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe("deriveMergeStatus", () => {
|
|
29
|
+
it("CONFLICTING mergeable → CONFLICTS", () => {
|
|
30
|
+
const result = deriveMergeStatus(makePr({ mergeable: "CONFLICTING" }));
|
|
31
|
+
expect(result.status).toBe("CONFLICTS");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("Copilot review requested → BLOCKED", () => {
|
|
35
|
+
const result = deriveMergeStatus(
|
|
36
|
+
makePr({ reviewRequests: [{ login: "copilot-pull-request-reviewer[bot]" }] }),
|
|
37
|
+
);
|
|
38
|
+
expect(result.status).toBe("BLOCKED");
|
|
39
|
+
expect(result.copilotReviewInProgress).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("Copilot review PENDING in latestReviews → BLOCKED", () => {
|
|
43
|
+
const result = deriveMergeStatus(
|
|
44
|
+
makePr({
|
|
45
|
+
latestReviews: [{ login: "copilot[bot]", state: "PENDING" }],
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
expect(result.status).toBe("BLOCKED");
|
|
49
|
+
expect(result.copilotReviewInProgress).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("Copilot review APPROVED in latestReviews → not copilotInProgress", () => {
|
|
53
|
+
const result = deriveMergeStatus(
|
|
54
|
+
makePr({
|
|
55
|
+
latestReviews: [{ login: "copilot[bot]", state: "APPROVED" }],
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
expect(result.copilotReviewInProgress).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("CONFLICTING takes priority over copilot blocked", () => {
|
|
62
|
+
const result = deriveMergeStatus(
|
|
63
|
+
makePr({
|
|
64
|
+
mergeable: "CONFLICTING",
|
|
65
|
+
reviewRequests: [{ login: "copilot[bot]" }],
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(result.status).toBe("CONFLICTS");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("BEHIND mergeStateStatus → BEHIND", () => {
|
|
72
|
+
const result = deriveMergeStatus(makePr({ mergeStateStatus: "BEHIND" }));
|
|
73
|
+
expect(result.status).toBe("BEHIND");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("BLOCKED mergeStateStatus → BLOCKED", () => {
|
|
77
|
+
const result = deriveMergeStatus(makePr({ mergeStateStatus: "BLOCKED" }));
|
|
78
|
+
expect(result.status).toBe("BLOCKED");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("UNSTABLE mergeStateStatus → UNSTABLE", () => {
|
|
82
|
+
const result = deriveMergeStatus(makePr({ mergeStateStatus: "UNSTABLE" }));
|
|
83
|
+
expect(result.status).toBe("UNSTABLE");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("isDraft → DRAFT", () => {
|
|
87
|
+
const result = deriveMergeStatus(makePr({ isDraft: true }));
|
|
88
|
+
expect(result.status).toBe("DRAFT");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("UNKNOWN mergeStateStatus → UNKNOWN", () => {
|
|
92
|
+
const result = deriveMergeStatus(makePr({ mergeStateStatus: "UNKNOWN" }));
|
|
93
|
+
expect(result.status).toBe("UNKNOWN");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("CLEAN mergeStateStatus → CLEAN", () => {
|
|
97
|
+
const result = deriveMergeStatus(makePr({ mergeStateStatus: "CLEAN" }));
|
|
98
|
+
expect(result.status).toBe("CLEAN");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("includes full detail fields in result", () => {
|
|
102
|
+
const result = deriveMergeStatus(
|
|
103
|
+
makePr({ reviewDecision: "CHANGES_REQUESTED", isDraft: false }),
|
|
104
|
+
);
|
|
105
|
+
expect(result.reviewDecision).toBe("CHANGES_REQUESTED");
|
|
106
|
+
expect(result.isDraft).toBe(false);
|
|
107
|
+
expect(result.mergeable).toBe("MERGEABLE");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("deriveMergeStatus — state pass-through", () => {
|
|
112
|
+
it("passes OPEN state through", () => {
|
|
113
|
+
const result = deriveMergeStatus(makePr({ state: "OPEN" }));
|
|
114
|
+
expect(result.state).toBe("OPEN");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("passes MERGED state through", () => {
|
|
118
|
+
const result = deriveMergeStatus(
|
|
119
|
+
makePr({ state: "MERGED", mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }),
|
|
120
|
+
);
|
|
121
|
+
expect(result.state).toBe("MERGED");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("passes CLOSED state through", () => {
|
|
125
|
+
const result = deriveMergeStatus(
|
|
126
|
+
makePr({ state: "CLOSED", mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }),
|
|
127
|
+
);
|
|
128
|
+
expect(result.state).toBe("CLOSED");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable JSON reporter.
|
|
3
|
+
*
|
|
4
|
+
* Slash commands parse this output to extract IDs, status, and actionable items
|
|
5
|
+
* without string-scraping the human-readable text reporter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ShepherdReport } from "../types.mts";
|
|
9
|
+
|
|
10
|
+
export function formatJson(report: ShepherdReport): string {
|
|
11
|
+
return JSON.stringify(report, null, 2);
|
|
12
|
+
}
|