diff --git a/src/miner/soft-claim.ts b/src/miner/soft-claim.ts new file mode 100644 index 0000000000..67320d846f --- /dev/null +++ b/src/miner/soft-claim.ts @@ -0,0 +1,42 @@ +// #2315 miner soft-claim spec builder. A miner can OPTIONALLY make its claim visible to a fleet by posting a public +// "soft claim" comment ("a miner is working on this") to reduce duplicate work. This module composes a +// deterministic, well-formatted claim comment BODY and hands it to the EXISTING `buildPostEligibilityCommentSpec` +// — it BUILDS the spec only and never executes anything, so the same spec-builder-not-actuator boundary documented +// by `src/mcp/local-write-tools.ts` (see `LOCAL_WRITE_BOUNDARY`, local-write-tools.ts:8) holds: the miner's OWN +// harness runs the command with its OWN GitHub credentials. Shell-safety is inherited from the reused builder's +// single-quote escaping — never re-implemented here. +import { + buildPostEligibilityCommentSpec, + type LocalWriteActionSpec, +} from "../mcp/local-write-tools"; + +/** Format the deterministic Markdown body of a soft-claim comment: which miner claimed the issue, when, and — when + * provided — when the claim lapses. Pure: the same inputs always produce the same string. The body's shell-safety + * is guaranteed downstream by {@link buildSoftClaimSpec} reusing `local-write-tools`' single-quote escaping. */ +export function buildSoftClaimCommentBody(input: { minerId: string; claimedAt: string; expiresAt?: string }): string { + const lines = [ + `🤖 **Soft claim** — a Gittensor miner (\`${input.minerId}\`) is working on this issue, claimed at ${input.claimedAt}.`, + ]; + if (input.expiresAt) { + lines.push( + `This claim expires at ${input.expiresAt}; if no PR has landed by then, the issue is open for others again.`, + ); + } + lines.push("_Soft claim only — not an assignment. It signals intent so the fleet avoids duplicate work._"); + return lines.join("\n\n"); +} + +/** Build the local-write action spec that posts a soft-claim comment on an issue. Composes + * {@link buildSoftClaimCommentBody} and delegates to the EXISTING `buildPostEligibilityCommentSpec` — reusing its + * shell-safe command construction/escaping rather than duplicating it — so the output stays deterministic and + * single-quote-shell-safe. Builds the spec only; the miner's own harness runs it (see `LOCAL_WRITE_BOUNDARY`). */ +export function buildSoftClaimSpec(input: { + repoFullName: string; + number: number; + minerId: string; + claimedAt: string; + expiresAt?: string; +}): LocalWriteActionSpec { + const body = buildSoftClaimCommentBody(input); + return buildPostEligibilityCommentSpec({ repoFullName: input.repoFullName, number: input.number, body }); +} diff --git a/test/unit/soft-claim.test.ts b/test/unit/soft-claim.test.ts new file mode 100644 index 0000000000..81fcf2d113 --- /dev/null +++ b/test/unit/soft-claim.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { LOCAL_WRITE_BOUNDARY } from "../../src/mcp/local-write-tools"; +import { buildSoftClaimCommentBody, buildSoftClaimSpec } from "../../src/miner/soft-claim"; + +describe("soft-claim spec builder (#2315)", () => { + it("buildSoftClaimCommentBody: formats a deterministic body with miner id + claimed-at, no expiry line when absent", () => { + const body = buildSoftClaimCommentBody({ minerId: "miner-7", claimedAt: "2026-07-03T12:00:00Z" }); + expect(body).toContain("`miner-7`"); + expect(body).toContain("claimed at 2026-07-03T12:00:00Z"); + expect(body).toContain("Soft claim only — not an assignment"); + expect(body).not.toContain("expires at"); // no expiry note without expiresAt + // deterministic + expect(buildSoftClaimCommentBody({ minerId: "miner-7", claimedAt: "2026-07-03T12:00:00Z" })).toBe(body); + }); + + it("buildSoftClaimCommentBody: includes an expiry note when expiresAt is provided", () => { + const body = buildSoftClaimCommentBody({ + minerId: "miner-7", + claimedAt: "2026-07-03T12:00:00Z", + expiresAt: "2026-07-04T12:00:00Z", + }); + expect(body).toContain("This claim expires at 2026-07-04T12:00:00Z"); + }); + + it("buildSoftClaimSpec: delegates to the eligibility-comment builder, carrying the local-write boundary", () => { + const spec = buildSoftClaimSpec({ + repoFullName: "octo/repo", + number: 42, + minerId: "miner-7", + claimedAt: "2026-07-03T12:00:00Z", + }); + expect(spec.action).toBe("post_eligibility_comment"); + expect(spec.boundary).toBe(LOCAL_WRITE_BOUNDARY); + // the command is the reused builder's shell-safe `gh issue comment`, with the composed body as --body + expect(spec.command).toContain("gh issue comment 42 --repo 'octo/repo' --body '"); + expect(spec.inputs.body).toBe(buildSoftClaimCommentBody({ minerId: "miner-7", claimedAt: "2026-07-03T12:00:00Z" })); + }); + + it("buildSoftClaimSpec: a single quote in minerId is POSIX-escaped by the reused builder (injection-safe)", () => { + const spec = buildSoftClaimSpec({ + repoFullName: "octo/repo", + number: 42, + minerId: "evil'; rm -rf / #", + claimedAt: "2026-07-03T12:00:00Z", + }); + // the embedded single quote is escaped as '\'' so the command stays a single, safe --body argument + expect(spec.command).toContain("evil'\\''; rm -rf / #"); + // and the whole --body value remains wrapped in one pair of single quotes (no unescaped break-out quote) + const unescaped = spec.command.replace(/'\\''/g, ""); + expect((unescaped.match(/'/g) ?? []).length % 2).toBe(0); + }); +});