Skip to content

Commit 8fe1c60

Browse files
1009904210099042
authored andcommitted
Add patch submission fallback
1 parent 52d1941 commit 8fe1c60

6 files changed

Lines changed: 177 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Every bug fix ships with a regression test, verified in a sandbox before payout.
2424
- `get_bounty_detail({ task_id_or_slug })`
2525
- `request_repo_access({ task_id, agent_id? })` — short-lived read-only clone URL for private code tasks.
2626
- `submit_pr({ task_id, agent_id, result_text, external_link, cover_note? })`
27+
- `submit_patch({ task_id, agent_id, result_text, patch_text?, patch_url?, cover_note? })` - fallback for private code tasks where the agent can clone the repo but cannot create an upstream PR. Provide exactly one of `patch_text` or `patch_url`.
2728
- `check_submission_status({ submission_id })`
2829

2930
## Install

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"README.md"
3232
],
3333
"scripts": {
34-
"build": "tsc && chmod +x build/index.js",
34+
"build": "tsc && node scripts/make-build-executable.mjs",
35+
"test": "npm run build && node --test tests/*.test.mjs",
3536
"start": "node build/index.js",
3637
"prepare": "npm run build"
3738
},

scripts/make-build-executable.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { chmodSync } from "node:fs";
2+
3+
chmodSync("build/index.js", 0o755);

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"environmentVariables": [
2020
{
2121
"name": "TASKBOUNTY_API_KEY",
22-
"description": "Your tb_live_* key from https://www.task-bounty.com/dashboard/api-keys. Required for write tools (submit_pr, create_bounty_draft, fund_bounty, list_my_bounties, get_bounty_submissions, award_bounty, cancel_bounty, request_repo_access, check_submission_status).",
22+
"description": "Your tb_live_* key from https://www.task-bounty.com/dashboard/api-keys. Required for write tools (submit_pr, submit_patch, create_bounty_draft, fund_bounty, list_my_bounties, get_bounty_submissions, award_bounty, cancel_bounty, request_repo_access, check_submission_status).",
2323
"isRequired": false,
2424
"isSecret": true
2525
},

src/index.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* TaskBounty MCP server — wraps https://www.task-bounty.com/api/v1/*
44
* Auth: set TASKBOUNTY_API_KEY (your tb_live_* key) in env.
55
*/
6+
import path from "node:path";
7+
import { fileURLToPath } from "node:url";
68
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
79
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
810
import {
@@ -20,6 +22,46 @@ type ToolResult = {
2022
isError?: boolean;
2123
};
2224

25+
type PatchSubmissionBuildResult =
26+
| { ok: true; body: Record<string, unknown> }
27+
| { ok: false; message: string };
28+
29+
export function buildPatchSubmissionBody(
30+
args: Record<string, unknown>,
31+
): PatchSubmissionBuildResult {
32+
const taskId = String(args.task_id ?? "");
33+
const agentId = String(args.agent_id ?? "");
34+
const resultText = String(args.result_text ?? "");
35+
const patchText = typeof args.patch_text === "string" ? args.patch_text.trim() : "";
36+
const patchUrl = typeof args.patch_url === "string" ? args.patch_url.trim() : "";
37+
38+
if (!taskId) return { ok: false, message: "task_id is required" };
39+
if (!agentId) return { ok: false, message: "agent_id is required" };
40+
if (!resultText) return { ok: false, message: "result_text is required" };
41+
if (!patchText && !patchUrl) {
42+
return { ok: false, message: "patch_text or patch_url is required" };
43+
}
44+
if (patchText && patchUrl) {
45+
return { ok: false, message: "Provide only one of patch_text or patch_url" };
46+
}
47+
48+
const body: Record<string, unknown> = {
49+
task_id: taskId,
50+
agent_id: agentId,
51+
result_text: resultText,
52+
submission_type: "patch",
53+
};
54+
55+
if (typeof args.cover_note === "string") body.cover_note = args.cover_note;
56+
if (patchText) body.patch_text = patchText;
57+
if (patchUrl) {
58+
body.patch_url = patchUrl;
59+
body.external_link = patchUrl;
60+
}
61+
62+
return { ok: true, body };
63+
}
64+
2365
async function tbFetch(
2466
path: string,
2567
init: RequestInit & { requireAuth?: boolean } = {},
@@ -117,7 +159,7 @@ const TOOLS = [
117159
{
118160
name: "request_repo_access",
119161
description:
120-
"For private code-task repos: mint a short-lived (~1h) read-only git clone URL. Read-only — push to your own fork to PR. Requires TASKBOUNTY_API_KEY.",
162+
"For private code-task repos: mint a short-lived (~1h) read-only git clone URL. If the private upstream cannot accept a PR from the agent, submit a unified diff with submit_patch. Requires TASKBOUNTY_API_KEY.",
121163
inputSchema: {
122164
type: "object",
123165
properties: {
@@ -155,6 +197,35 @@ const TOOLS = [
155197
required: ["task_id", "agent_id", "result_text", "external_link"],
156198
},
157199
},
200+
{
201+
name: "submit_patch",
202+
description:
203+
"Submit a patch artifact when a private upstream repo cannot be forked or opened as a PR by the agent. Provide either patch_text (unified diff) or patch_url. Requires TASKBOUNTY_API_KEY.",
204+
inputSchema: {
205+
type: "object",
206+
properties: {
207+
task_id: { type: "string" },
208+
agent_id: { type: "string" },
209+
result_text: {
210+
type: "string",
211+
description: "Summary of the work done and tests run.",
212+
},
213+
patch_text: {
214+
type: "string",
215+
description: "Unified diff created with git diff or git format-patch.",
216+
},
217+
patch_url: {
218+
type: "string",
219+
description: "URL to a patch artifact, for example a GitHub gist or raw patch file.",
220+
},
221+
cover_note: {
222+
type: "string",
223+
description: "Optional note to the task poster.",
224+
},
225+
},
226+
required: ["task_id", "agent_id", "result_text"],
227+
},
228+
},
158229
{
159230
name: "check_submission_status",
160231
description:
@@ -333,6 +404,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
333404
});
334405
}
335406

407+
case "submit_patch": {
408+
const result = buildPatchSubmissionBody(a);
409+
if (!result.ok) {
410+
return {
411+
content: [{ type: "text", text: result.message }],
412+
isError: true,
413+
};
414+
}
415+
return await tbFetch(`/submissions`, {
416+
method: "POST",
417+
body: JSON.stringify(result.body),
418+
requireAuth: true,
419+
});
420+
}
421+
336422
case "check_submission_status": {
337423
const id = String(a.submission_id ?? "");
338424
if (!id) {
@@ -467,7 +553,13 @@ async function main() {
467553
console.error("[taskbounty-mcp] ready on stdio");
468554
}
469555

470-
main().catch((err) => {
471-
console.error("[taskbounty-mcp] fatal", err);
472-
process.exit(1);
473-
});
556+
const isCliEntry =
557+
process.argv[1] !== undefined &&
558+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
559+
560+
if (isCliEntry) {
561+
main().catch((err) => {
562+
console.error("[taskbounty-mcp] fatal", err);
563+
process.exit(1);
564+
});
565+
}

tests/patch-submission.test.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import { buildPatchSubmissionBody } from "../build/index.js";
5+
6+
describe("buildPatchSubmissionBody", () => {
7+
it("builds an inline patch submission without requiring a PR URL", () => {
8+
const result = buildPatchSubmissionBody({
9+
task_id: "task-1",
10+
agent_id: "agent-1",
11+
result_text: "Fixed the DST crash and added a regression test.",
12+
patch_text: "diff --git a/src/lib/utils.ts b/src/lib/utils.ts\n",
13+
});
14+
15+
assert.equal(result.ok, true);
16+
assert.deepEqual(result.body, {
17+
task_id: "task-1",
18+
agent_id: "agent-1",
19+
result_text: "Fixed the DST crash and added a regression test.",
20+
submission_type: "patch",
21+
patch_text: "diff --git a/src/lib/utils.ts b/src/lib/utils.ts",
22+
});
23+
});
24+
25+
it("uses a patch artifact URL as the external link for compatibility", () => {
26+
const result = buildPatchSubmissionBody({
27+
task_id: "task-1",
28+
agent_id: "agent-1",
29+
result_text: "Fixed the issue.",
30+
patch_url: "https://gist.github.com/example/patch.diff",
31+
cover_note: "Private repo PR creation was not available.",
32+
});
33+
34+
assert.equal(result.ok, true);
35+
assert.deepEqual(result.body, {
36+
task_id: "task-1",
37+
agent_id: "agent-1",
38+
result_text: "Fixed the issue.",
39+
submission_type: "patch",
40+
cover_note: "Private repo PR creation was not available.",
41+
patch_url: "https://gist.github.com/example/patch.diff",
42+
external_link: "https://gist.github.com/example/patch.diff",
43+
});
44+
});
45+
46+
it("rejects submissions without a patch artifact", () => {
47+
const result = buildPatchSubmissionBody({
48+
task_id: "task-1",
49+
agent_id: "agent-1",
50+
result_text: "Fixed the issue.",
51+
});
52+
53+
assert.deepEqual(result, {
54+
ok: false,
55+
message: "patch_text or patch_url is required",
56+
});
57+
});
58+
59+
it("rejects ambiguous patch artifacts", () => {
60+
const result = buildPatchSubmissionBody({
61+
task_id: "task-1",
62+
agent_id: "agent-1",
63+
result_text: "Fixed the issue.",
64+
patch_text: "diff --git a/file b/file\n",
65+
patch_url: "https://gist.github.com/example/patch.diff",
66+
});
67+
68+
assert.deepEqual(result, {
69+
ok: false,
70+
message: "Provide only one of patch_text or patch_url",
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)