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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
- run: npm test
- run: npm run build
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
],
"scripts": {
"build": "tsc && chmod +x build/index.js",
"test": "tsc && node --test test/*.test.mjs",
"start": "node build/index.js",
"prepare": "npm run build"
},
Expand Down
122 changes: 31 additions & 91 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
writeFileSync,
existsSync,
} from "node:fs";
import { parseGitHubRepo, requireNonEmpty, toolError, type ToolResult } from "./validators.js";

const API_BASE =
process.env.TASKBOUNTY_API_BASE?.replace(/\/$/, "") ||
Expand All @@ -85,10 +86,6 @@ const SITE_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
const CRED_DIR = join(homedir(), ".taskbounty");
const CRED_PATH = join(CRED_DIR, "credentials.json");

type ToolResult = {
content: { type: "text"; text: string }[];
isError?: boolean;
};

function readStoredToken(): string {
try {
Expand Down Expand Up @@ -756,29 +753,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
isError: true,
};
}
const repoRaw = String(a.repo ?? "").trim();
if (!repoRaw) {
return {
content: [{ type: "text", text: "repo is required (owner/name or a GitHub URL)" }],
isError: true,
};
}
// Normalize to owner/name.
const m = repoRaw.match(
/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s#?]+?)(?:\.git)?\/?$/i,
);
if (!m) {
return {
content: [
{
type: "text",
text: `Could not parse repo "${repoRaw}". Use owner/name or a full GitHub URL.`,
},
],
isError: true,
};
}
const repo = `${m[1]}/${m[2]}`;
const repoResult = parseGitHubRepo(a.repo);
if (!repoResult.ok) return repoResult.result;
const repo = repoResult.value;
const triggerLabel =
typeof a.trigger_label === "string" && a.trigger_label
? a.trigger_label
Expand Down Expand Up @@ -844,13 +821,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
isError: true,
};
}
const issueUrl = String(a.issue_url ?? "").trim();
if (!issueUrl) {
return {
content: [{ type: "text", text: "issue_url is required" }],
isError: true,
};
}
const issueUrlErr = requireNonEmpty(a.issue_url, "issue_url");
if (issueUrlErr) return issueUrlErr;
const issueUrl = String(a.issue_url).trim();
const body: Record<string, unknown> = { issue_url: issueUrl };
if (typeof a.bounty_usd === "number") body.bounty_usd = a.bounty_usd;

Expand Down Expand Up @@ -920,13 +893,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "get_bounty_detail": {
const id = String(a.task_id_or_slug ?? "");
if (!id) {
return {
content: [{ type: "text", text: "task_id_or_slug is required" }],
isError: true,
};
}
const slugErr = requireNonEmpty(a.task_id_or_slug, "task_id_or_slug");
if (slugErr) return slugErr;
const id = String(a.task_id_or_slug).trim();
return await tbFetch(`/tasks/${encodeURIComponent(id)}`);
}

Expand Down Expand Up @@ -963,27 +932,18 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "check_submission_status": {
const id = String(a.submission_id ?? "");
if (!id) {
return {
content: [{ type: "text", text: "submission_id is required" }],
isError: true,
};
}
const subErr = requireNonEmpty(a.submission_id, "submission_id");
if (subErr) return subErr;
const id = String(a.submission_id).trim();
return await tbFetch(`/submissions/${encodeURIComponent(id)}`, {
requireAuth: true,
});
}

case "create_bounty_draft": {
const required = ["title", "short_summary", "description", "category", "bounty_amount", "submission_deadline"];
for (const key of required) {
if (a[key] === undefined || a[key] === null || a[key] === "") {
return {
content: [{ type: "text", text: `${key} is required` }],
isError: true,
};
}
for (const field of ["title", "short_summary", "description", "category", "bounty_amount", "submission_deadline"] as const) {
const err = requireNonEmpty(a[field], field);
if (err) return err;
}
const body: Record<string, unknown> = {
title: a.title,
Expand All @@ -1007,13 +967,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "fund_bounty": {
const taskId = String(a.task_id ?? "");
if (!taskId) {
return {
content: [{ type: "text", text: "task_id is required" }],
isError: true,
};
}
const fundErr = requireNonEmpty(a.task_id, "task_id");
if (fundErr) return fundErr;
const taskId = String(a.task_id).trim();
return await tbFetch(`/tasks/${encodeURIComponent(taskId)}/checkout`, {
method: "POST",
body: JSON.stringify({}),
Expand All @@ -1033,33 +989,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "get_bounty_submissions": {
const taskId = String(a.task_id ?? "");
if (!taskId) {
return {
content: [{ type: "text", text: "task_id is required" }],
isError: true,
};
}
const gbsErr = requireNonEmpty(a.task_id, "task_id");
if (gbsErr) return gbsErr;
const taskId = String(a.task_id).trim();
return await tbFetch(`/tasks/${encodeURIComponent(taskId)}/submissions`, {
requireAuth: true,
});
}

case "award_bounty": {
const taskId = String(a.task_id ?? "");
const submissionId = String(a.submission_id ?? "");
if (!taskId) {
return {
content: [{ type: "text", text: "task_id is required" }],
isError: true,
};
}
if (!submissionId) {
return {
content: [{ type: "text", text: "submission_id is required" }],
isError: true,
};
}
const awardTaskErr = requireNonEmpty(a.task_id, "task_id");
if (awardTaskErr) return awardTaskErr;
const awardSubErr = requireNonEmpty(a.submission_id, "submission_id");
if (awardSubErr) return awardSubErr;
const taskId = String(a.task_id).trim();
const submissionId = String(a.submission_id).trim();
return await tbFetch(`/tasks/${encodeURIComponent(taskId)}/award`, {
method: "POST",
body: JSON.stringify({ submission_id: submissionId }),
Expand All @@ -1068,13 +1012,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
}

case "cancel_bounty": {
const taskId = String(a.task_id ?? "");
if (!taskId) {
return {
content: [{ type: "text", text: "task_id is required" }],
isError: true,
};
}
const cancelErr = requireNonEmpty(a.task_id, "task_id");
if (cancelErr) return cancelErr;
const taskId = String(a.task_id).trim();
return await tbFetch(`/tasks/${encodeURIComponent(taskId)}/cancel`, {
method: "POST",
body: JSON.stringify({}),
Expand Down
44 changes: 44 additions & 0 deletions src/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type ToolResult = {
content: { type: "text"; text: string }[];
isError?: boolean;
};

export type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; result: ToolResult };

export function toolError(text: string): ToolResult {
return { content: [{ type: "text", text }], isError: true };
}

/**
* Normalises a raw GitHub repo input to "owner/name".
* Accepts: "owner/name", full GitHub URLs (http or https),
* .git suffix, trailing slash, and ?query / #fragment suffixes.
*/
export function parseGitHubRepo(raw: unknown): ParseResult<string> {
const s = String(raw ?? "").trim();
if (!s) {
return { ok: false, result: toolError("repo is required (owner/name or a GitHub URL)") };
}
const m = s.match(
/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s#?]+?)(?:\.git)?\/?(?:[?#].*)?$/i,
);
if (!m) {
return {
ok: false,
result: toolError(`Could not parse repo "${s}". Use owner/name or a full GitHub URL.`),
};
}
return { ok: true, value: `${m[1]}/${m[2]}` };
}

/**
* Returns a ToolResult error when `raw` is empty/null/undefined/whitespace,
* or null when the field is present.
*/
export function requireNonEmpty(raw: unknown, fieldName: string): ToolResult | null {
const s = String(raw ?? "").trim();
if (!s) return toolError(`${fieldName} is required`);
return null;
}
142 changes: 142 additions & 0 deletions test/validators.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { parseGitHubRepo, requireNonEmpty } from "../build/validators.js";

// ── parseGitHubRepo ───────────────────────────────────────────────────────────

describe("parseGitHubRepo", () => {
// Happy paths
it("accepts owner/name", () => {
assert.deepEqual(parseGitHubRepo("acme/widgets"), { ok: true, value: "acme/widgets" });
});

it("accepts https GitHub URL", () => {
assert.deepEqual(parseGitHubRepo("https://github.com/acme/widgets"), {
ok: true,
value: "acme/widgets",
});
});

it("accepts http GitHub URL", () => {
assert.deepEqual(parseGitHubRepo("http://github.com/acme/widgets"), {
ok: true,
value: "acme/widgets",
});
});

it("strips .git suffix", () => {
assert.deepEqual(parseGitHubRepo("https://github.com/acme/widgets.git"), {
ok: true,
value: "acme/widgets",
});
});

it("strips .git suffix and trailing slash", () => {
assert.deepEqual(parseGitHubRepo("https://github.com/acme/widgets.git/"), {
ok: true,
value: "acme/widgets",
});
});

it("strips query string", () => {
assert.deepEqual(parseGitHubRepo("https://github.com/acme/widgets?tab=issues"), {
ok: true,
value: "acme/widgets",
});
});

it("strips fragment", () => {
assert.deepEqual(parseGitHubRepo("https://github.com/acme/widgets#readme"), {
ok: true,
value: "acme/widgets",
});
});

it("strips trailing slash without .git", () => {
assert.deepEqual(parseGitHubRepo("acme/widgets/"), {
ok: true,
value: "acme/widgets",
});
});

// Error paths
it("rejects empty string", () => {
const r = parseGitHubRepo("");
assert.equal(r.ok, false);
if (r.ok) throw new Error("expected error");
assert.equal(r.result.isError, true);
assert.equal(r.result.content[0]?.text, "repo is required (owner/name or a GitHub URL)");
});

it("rejects null", () => {
const r = parseGitHubRepo(null);
assert.equal(r.ok, false);
if (r.ok) throw new Error("expected error");
assert.equal(r.result.isError, true);
assert.match(r.result.content[0]?.text ?? "", /repo is required/);
});

it("rejects undefined", () => {
const r = parseGitHubRepo(undefined);
assert.equal(r.ok, false);
if (r.ok) throw new Error("expected error");
assert.match(r.result.content[0]?.text ?? "", /repo is required/);
});

it("rejects plain string with no slash", () => {
const r = parseGitHubRepo("not-a-repo");
assert.equal(r.ok, false);
if (r.ok) throw new Error("expected error");
assert.equal(r.result.isError, true);
assert.match(r.result.content[0]?.text ?? "", /Could not parse repo/);
});

it("rejects whitespace-only input", () => {
const r = parseGitHubRepo(" ");
assert.equal(r.ok, false);
if (r.ok) throw new Error("expected error");
assert.match(r.result.content[0]?.text ?? "", /repo is required/);
});
});

// ── requireNonEmpty ───────────────────────────────────────────────────────────

describe("requireNonEmpty", () => {
it("returns null for a valid string", () => {
assert.equal(requireNonEmpty("abc-123", "task_id"), null);
});

it("errors on empty string", () => {
const r = requireNonEmpty("", "task_id");
assert.notEqual(r, null);
assert.equal(r?.isError, true);
assert.equal(r?.content[0]?.text, "task_id is required");
});

it("errors on whitespace-only string", () => {
const r = requireNonEmpty(" ", "submission_id");
assert.notEqual(r, null);
assert.equal(r?.isError, true);
assert.equal(r?.content[0]?.text, "submission_id is required");
});

it("errors on null", () => {
const r = requireNonEmpty(null, "issue_url");
assert.notEqual(r, null);
assert.equal(r?.isError, true);
assert.equal(r?.content[0]?.text, "issue_url is required");
});

it("errors on undefined", () => {
const r = requireNonEmpty(undefined, "task_id_or_slug");
assert.notEqual(r, null);
assert.equal(r?.isError, true);
assert.equal(r?.content[0]?.text, "task_id_or_slug is required");
});

it("uses the field name verbatim in the error message", () => {
const r = requireNonEmpty("", "bounty_amount");
assert.equal(r?.content[0]?.text, "bounty_amount is required");
});
});