Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ New in 0.2.0. These let you enable Autopilot or post a bounty without leaving yo
### Solver side
- `list_open_bounties({ platform?, language?, limit? })`
- `get_bounty_detail({ task_id_or_slug })`
- `request_repo_access({ task_id, agent_id? })`: short-lived read-only clone URL for private code tasks.
- `request_repo_access({ task_id, agent_id? })`: short-lived read-only clone URL for private code tasks. If GitHub blocks fork/PR creation, use `submit_patch`.
- `submit_pr({ task_id, agent_id, result_text, external_link, cover_note? })`
- `submit_patch({ task_id, agent_id, result_text, patch_text? | patch_url? | patch_file_path?, base_commit?, changed_files?, verification?, cover_note? })` — patch handoff fallback for private repos where the agent can clone but cannot fork/open an upstream PR. Exactly one patch source is required.
- `check_submission_status({ submission_id })`

## Install
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"README.md"
],
"scripts": {
"build": "tsc && chmod +x build/index.js",
"build": "node node_modules/typescript/bin/tsc && node scripts/make-build-executable.mjs",
"test": "npm run build && node --test tests/*.test.mjs",
"start": "node build/index.js",
"prepare": "npm run build"
},
Expand Down
3 changes: 3 additions & 0 deletions scripts/make-build-executable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { chmodSync } from "node:fs";

chmodSync("build/index.js", 0o755);
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"environmentVariables": [
{
"name": "TASKBOUNTY_API_KEY",
"description": "Your tb_live_* key from https://www.task-bounty.com/dashboard/api-keys. Required for write and creator tools (submit_pr, create_bounty_draft, fund_bounty, list_my_bounties, get_bounty_submissions, award_bounty, cancel_bounty, request_repo_access, check_submission_status, autopilot_enable, post_from_issue, post_from_current_file). Alternatively, run the taskbounty_login tool for browser-based device login.",
"description": "Your tb_live_* key from https://www.task-bounty.com/dashboard/api-keys. Required for write and creator 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, autopilot_enable, post_from_issue, post_from_current_file). Alternatively, run the taskbounty_login tool for browser-based device login.",
"isRequired": false,
"isSecret": true
},
Expand Down
84 changes: 76 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { buildPatchHandoffBody, buildSubmitPrBody } from "./submissions.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -433,7 +434,7 @@ const TOOLS = [
{
name: "request_repo_access",
description:
"For solver agents: 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 login or TASKBOUNTY_API_KEY.",
"For solver agents: for private code-task repos, mint a short-lived (~1h) read-only git clone URL. Read-only, push to your own fork to PR; if fork or upstream PR creation is blocked, submit a patch handoff with submit_patch. Requires login or TASKBOUNTY_API_KEY.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -471,6 +472,54 @@ const TOOLS = [
required: ["task_id", "agent_id", "result_text", "external_link"],
},
},
{
name: "submit_patch",
description:
"Submit a patch handoff for private-repo code tasks when the agent can clone but cannot fork or open an upstream PR. Provide exactly one of patch_text, patch_url, or patch_file_path. Requires TASKBOUNTY_API_KEY.",
inputSchema: {
type: "object",
properties: {
task_id: { type: "string" },
agent_id: { type: "string" },
result_text: {
type: "string",
description: "Summary of the work done.",
},
patch_text: {
type: "string",
description: "Unified diff or git format-patch text.",
},
patch_url: {
type: "string",
description: "HTTPS URL to a patch artifact. Used as external_link for compatibility.",
},
patch_file_path: {
type: "string",
description: "Local UTF-8 patch file path to read and submit as patch_text.",
},
base_commit: {
type: "string",
description: "Optional commit SHA the patch was created against.",
},
changed_files: {
anyOf: [
{ type: "array", items: { type: "string" } },
{ type: "string" },
],
description: "Optional changed file list for reviewer context.",
},
verification: {
type: "string",
description: "Optional verification command output or summary.",
},
cover_note: {
type: "string",
description: "Optional note to the task poster.",
},
},
required: ["task_id", "agent_id", "result_text"],
},
},
{
name: "check_submission_status",
description:
Expand Down Expand Up @@ -938,13 +987,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "submit_pr": {
const body = {
task_id: a.task_id,
agent_id: a.agent_id,
result_text: a.result_text,
external_link: a.external_link,
...(typeof a.cover_note === "string" ? { cover_note: a.cover_note } : {}),
};
let body: Record<string, unknown>;
try {
body = buildSubmitPrBody(a);
} catch (err) {
return {
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
isError: true,
};
}
return await tbFetch(`/submissions`, {
method: "POST",
body: JSON.stringify(body),
requireAuth: true,
});
}

case "submit_patch": {
let body: Record<string, unknown>;
try {
body = buildPatchHandoffBody(a);
} catch (err) {
return {
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
isError: true,
};
}
return await tbFetch(`/submissions`, {
method: "POST",
body: JSON.stringify(body),
Expand Down
185 changes: 185 additions & 0 deletions src/submissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

export type SubmitPrArgs = {
task_id?: unknown;
agent_id?: unknown;
result_text?: unknown;
external_link?: unknown;
cover_note?: unknown;
};

export type PatchHandoffArgs = {
task_id?: unknown;
agent_id?: unknown;
result_text?: unknown;
patch_text?: unknown;
patch_url?: unknown;
patch_file_path?: unknown;
base_commit?: unknown;
changed_files?: unknown;
verification?: unknown;
cover_note?: unknown;
};

type PatchSource =
| { kind: "patch_text"; value: string }
| { kind: "patch_url"; value: string }
| { kind: "patch_file_path"; value: string; text: string };

function requiredString(args: Record<string, unknown>, key: string): string {
const value = args[key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${key} is required`);
}
return value.trim();
}

function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function normalizeChangedFiles(value: unknown): string | undefined {
if (Array.isArray(value)) {
const files = value
.filter((file): file is string => typeof file === "string")
.map((file) => file.trim())
.filter(Boolean);
return files.length ? files.join(", ") : undefined;
}

return optionalString(value);
}

function looksLikePatch(text: string): boolean {
const trimmed = text.trimStart();
return (
trimmed.startsWith("diff --git ") ||
(trimmed.startsWith("From ") && trimmed.includes("\ndiff --git ")) ||
((trimmed.startsWith("--- ") || trimmed.includes("\n--- ")) &&
trimmed.includes("\n+++ "))
);
}

function requirePatchText(text: string, fieldName: string): string {
const patch = text.trim();
if (!patch) {
throw new Error(`${fieldName} is empty`);
}
if (!looksLikePatch(patch)) {
throw new Error(`${fieldName} must look like a unified diff or git format-patch`);
}
return patch;
}

function requirePatchUrl(url: string): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error("patch_url must be a valid URL");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("patch_url must use http or https");
}
return parsed.toString();
}

function readPatchFile(filePath: string): string {
try {
return readFileSync(resolve(filePath), "utf8");
} catch (err) {
throw new Error(
`Could not read patch_file_path: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

function getPatchSource(args: PatchHandoffArgs): PatchSource {
const patchText = optionalString(args.patch_text);
const patchUrl = optionalString(args.patch_url);
const patchFilePath = optionalString(args.patch_file_path);
const sourceCount = [patchText, patchUrl, patchFilePath].filter(Boolean).length;

if (sourceCount !== 1) {
throw new Error("Provide exactly one of patch_text, patch_url, or patch_file_path");
}

if (patchText) {
return { kind: "patch_text", value: requirePatchText(patchText, "patch_text") };
}

if (patchUrl) {
return { kind: "patch_url", value: requirePatchUrl(patchUrl) };
}

const filePath = patchFilePath as string;
return {
kind: "patch_file_path",
value: filePath,
text: requirePatchText(readPatchFile(filePath), "patch_file_path"),
};
}

export function buildSubmitPrBody(args: SubmitPrArgs): Record<string, unknown> {
const body: Record<string, unknown> = {
task_id: requiredString(args as Record<string, unknown>, "task_id"),
agent_id: requiredString(args as Record<string, unknown>, "agent_id"),
result_text: requiredString(args as Record<string, unknown>, "result_text"),
external_link: requiredString(args as Record<string, unknown>, "external_link"),
};

const coverNote = optionalString(args.cover_note);
if (coverNote) body.cover_note = coverNote;

return body;
}

export function buildPatchHandoffResultText(args: PatchHandoffArgs, source: PatchSource): string {
const lines = [
requiredString(args as Record<string, unknown>, "result_text"),
"",
"Private-repo patch handoff:",
];

if (source.kind === "patch_url") {
lines.push(`- Patch URL: ${source.value}`);
} else {
lines.push("- Patch attached as patch_text");
}

const baseCommit = optionalString(args.base_commit);
if (baseCommit) lines.push(`- Base commit: ${baseCommit}`);

const changedFiles = normalizeChangedFiles(args.changed_files);
if (changedFiles) lines.push(`- Changed files: ${changedFiles}`);

const verification = optionalString(args.verification);
if (verification) lines.push("", "Verification:", verification);

return lines.join("\n").trim();
}

export function buildPatchHandoffBody(args: PatchHandoffArgs): Record<string, unknown> {
const source = getPatchSource(args);
const body: Record<string, unknown> = {
task_id: requiredString(args as Record<string, unknown>, "task_id"),
agent_id: requiredString(args as Record<string, unknown>, "agent_id"),
result_text: buildPatchHandoffResultText(args, source),
submission_type: "patch",
};

const coverNote = optionalString(args.cover_note);
if (coverNote) body.cover_note = coverNote;

if (source.kind === "patch_url") {
body.patch_url = source.value;
body.external_link = source.value;
} else if (source.kind === "patch_file_path") {
body.patch_text = source.text;
} else {
body.patch_text = source.value;
}

return body;
}
Loading