Skip to content

Commit cd9867e

Browse files
committed
fix(ship): address coderabbit critical findings on pr #75
- resolve-review-thread mcp: do a read-only preflight query to verify the thread belongs to the bound pr before issuing the resolve mutation. the previous flow ran the mutation first and only rejected cross-pr threads after the side effect had already happened. - eligibility: fr-015/fr-028 require the gate to evaluate the triggering principal only. drop the ownerOk fallback, which let any commenter trigger the bot whenever the repo owner happened to be in ALLOWED_OWNERS. - tickle-scheduler: requeue the intent on dispatch failure. ZREM ran before onDue, so a thrown onDue stranded the session until startup reconciliation re-ran. requeue with a one-interval delay keeps the session live without tight-looping a known-bad dispatch. - test-isolated.sh: fail fast when the test glob matches nothing. nullglob silently turned an empty match into a successful no-op, hiding a deleted test directory or broken glob behind a green ci.
1 parent 1dbf70f commit cd9867e

4 files changed

Lines changed: 44 additions & 9 deletions

File tree

‎scripts/test-isolated.sh‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ passed=0
99
failed=0
1010
failures=()
1111

12-
for f in test/**/*.test.ts; do
12+
tests=(test/**/*.test.ts)
13+
if (( ${#tests[@]} == 0 )); then
14+
echo "No test files matched test/**/*.test.ts" >&2
15+
exit 1
16+
fi
17+
18+
for f in "${tests[@]}"; do
1319
output=$(bun test "$f" 2>&1)
1420
has_zero_fail=false
1521
has_skip=false

‎src/mcp/servers/resolve-review-thread.ts‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ const server = new McpServer({
5151
version: "1.0.0",
5252
});
5353

54+
const GET_THREAD_QUERY = `
55+
query GetReviewThread($threadId: ID!) {
56+
node(id: $threadId) {
57+
... on PullRequestReviewThread {
58+
id
59+
pullRequest {
60+
number
61+
}
62+
}
63+
}
64+
}
65+
`;
66+
5467
const RESOLVE_MUTATION = `
5568
mutation ResolveReviewThread($threadId: ID!) {
5669
resolveReviewThread(input: { threadId: $threadId }) {
@@ -65,6 +78,13 @@ const RESOLVE_MUTATION = `
6578
}
6679
`;
6780

81+
interface PreflightResponse {
82+
node: {
83+
id: string;
84+
pullRequest: { number: number };
85+
} | null;
86+
}
87+
6888
interface ResolveResponse {
6989
resolveReviewThread: {
7090
thread: {
@@ -117,19 +137,21 @@ server.tool(
117137
},
118138
async ({ thread_id }) => {
119139
try {
120-
const result = await octokit.graphql<ResolveResponse>(RESOLVE_MUTATION, {
140+
const preflight = await octokit.graphql<PreflightResponse>(GET_THREAD_QUERY, {
121141
threadId: thread_id,
122142
});
123-
const thread = result.resolveReviewThread.thread;
124-
125-
if (thread.pullRequest.number !== BOUND_PR_NUMBER) {
143+
const preflightPr = preflight.node?.pullRequest.number;
144+
if (preflightPr !== BOUND_PR_NUMBER) {
126145
return {
127146
content: [
128147
{
129148
type: "text" as const,
130149
text: JSON.stringify({
131150
code: "graphql_error",
132-
message: `thread belongs to PR #${String(thread.pullRequest.number)} but this server is bound to PR #${String(BOUND_PR_NUMBER)}`,
151+
message:
152+
preflightPr === undefined
153+
? `thread ${thread_id} not found or not a PullRequestReviewThread`
154+
: `thread belongs to PR #${String(preflightPr)} but this server is bound to PR #${String(BOUND_PR_NUMBER)}`,
133155
thread_id,
134156
}),
135157
},
@@ -138,6 +160,11 @@ server.tool(
138160
};
139161
}
140162

163+
const result = await octokit.graphql<ResolveResponse>(RESOLVE_MUTATION, {
164+
threadId: thread_id,
165+
});
166+
const thread = result.resolveReviewThread.thread;
167+
141168
return {
142169
content: [
143170
{

‎src/workflows/ship/eligibility.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ export async function checkEligibility(input: EligibilityInput): Promise<Eligibi
6969
const triggerOk = allowedOwners.some(
7070
(o) => o.toLowerCase() === input.triggeringUserLogin.toLowerCase(),
7171
);
72-
const ownerOk = allowedOwners.some((o) => o.toLowerCase() === input.owner.toLowerCase());
73-
if (!triggerOk && !ownerOk) {
72+
if (!triggerOk) {
7473
return {
7574
eligible: false,
7675
reason: "unauthorized",

‎src/workflows/ship/tickle-scheduler.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ export function createTickleScheduler(deps: TickleSchedulerDeps): TickleSchedule
7575
try {
7676
await deps.onDue(intent_id);
7777
} catch (err) {
78+
// Re-arm so a transient dispatch failure doesn't strand the
79+
// session until startup reconciliation runs again.
80+
await deps.valkey.send("ZADD", [TICKLE_KEY, String(Date.now() + intervalMs), intent_id]);
7881
logger.error(
7982
{ event: "ship.tickle.dispatch_failed", intent_id, err: String(err) },
80-
"ship tickle dispatch failed",
83+
"ship tickle dispatch failed; requeued",
8184
);
8285
}
8386
}

0 commit comments

Comments
 (0)