|
| 1 | +import { writeFileSync } from "node:fs"; |
| 2 | +import { join } from "node:path"; |
| 3 | +import { |
| 4 | + ACCEPTANCE_CRITERIA_FILENAME, |
| 5 | + buildAcceptanceCriteria, |
| 6 | + buildCollisionReport, |
| 7 | + buildFeasibilityVerdict, |
| 8 | + buildPromptPacket, |
| 9 | + feasibilityInputFromPreStartCheck, |
| 10 | + serializeAcceptanceCriteria, |
| 11 | + shouldWriteAcceptanceCriteria, |
| 12 | +} from "@jsonbored/gittensory-engine"; |
| 13 | + |
| 14 | +// Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI |
| 15 | +// wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this |
| 16 | +// package. `packages/gittensory-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + |
| 17 | +// FeasibilityGateResult into an immutable AcceptanceCriteria document (and deliberately does NOT write it -- |
| 18 | +// "actually writing it into the attempt's worktree is the worktree primitive's job", per its own header) -- |
| 19 | +// this module is that caller: derives the four inputs from a real target issue + the already-fetched |
| 20 | +// SelfReviewContext (#5145), then writes the file for real. |
| 21 | +// |
| 22 | +// issueStatus is intentionally left undefined when computing feasibility: buildIssueQualityReport (the only |
| 23 | +// thing that could supply it) lives only in root src/signals/engine.ts and has never been extracted into |
| 24 | +// @jsonbored/gittensory-engine (same gap #5145's own header documents for `issueQuality`). This is not a |
| 25 | +// fabrication -- feasibilityInputFromPreStartCheck's OWN documented default for a missing |
| 26 | +// issueQualityStatus/lifecycle is "ready", the same honest-default precedent already established. |
| 27 | + |
| 28 | +function buildTaskBrief(issue) { |
| 29 | + const body = (issue.body ?? "").trim(); |
| 30 | + return body ? `${issue.title}\n\n${body}` : issue.title; |
| 31 | +} |
| 32 | + |
| 33 | +function buildConstraints(issue) { |
| 34 | + if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; |
| 35 | + return `Labels on this issue: ${issue.labels.join(", ")}.`; |
| 36 | +} |
| 37 | + |
| 38 | +function buildFeasibilityNotes(feasibility) { |
| 39 | + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); |
| 40 | +} |
| 41 | + |
| 42 | +// Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary |
| 43 | +// ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to |
| 44 | +// map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), |
| 45 | +// which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. |
| 46 | +function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { |
| 47 | + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); |
| 48 | + return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; |
| 49 | +} |
| 50 | + |
| 51 | +// The target issue's own raw cluster risk from buildCollisionReport (newly exported from |
| 52 | +// @jsonbored/gittensory-engine's public barrel) -- "none" when the issue isn't part of any cluster at all. |
| 53 | +// DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to |
| 54 | +// stop inDuplicateCluster (self-review, "does MY OWN just-created submission look redundant") from firing on |
| 55 | +// the ordinary case of one existing PR already legitimately closing the issue. Feasibility asks a different |
| 56 | +// question -- "should I even START working on this issue" -- where an issue already having ANY open PR |
| 57 | +// against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly |
| 58 | +// one PR) is a meaningful, real caution signal, not a false positive to filter out. |
| 59 | +function resolveDuplicateClusterRisk(repoFullName, issues, pullRequests, issueNumber) { |
| 60 | + const report = buildCollisionReport(repoFullName, issues, pullRequests); |
| 61 | + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); |
| 62 | + return cluster ? cluster.risk : "none"; |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in |
| 67 | + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk |
| 68 | + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented |
| 69 | + * "ready" default -- see this file's header for why that's honest, not fabricated. |
| 70 | + * |
| 71 | + * @param {string} repoFullName |
| 72 | + * @param {{ number: number }} issue |
| 73 | + * @param {{ issues: Array<{ number: number }>, pullRequests: unknown[] }} context |
| 74 | + * @param {{ listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }} claimLedger |
| 75 | + * @returns {import("@jsonbored/gittensory-engine").FeasibilityGateResult} |
| 76 | + */ |
| 77 | +export function buildCodingTaskFeasibility(repoFullName, issue, context, claimLedger) { |
| 78 | + const found = context.issues.some((candidate) => candidate.number === issue.number); |
| 79 | + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); |
| 80 | + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); |
| 81 | + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); |
| 82 | + return buildFeasibilityVerdict(feasibilityInput); |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. |
| 87 | + * |
| 88 | + * @param {{ title: string, body?: string | null, labels?: string[] }} issue |
| 89 | + * @param {import("@jsonbored/gittensory-engine").FeasibilityGateResult} feasibility |
| 90 | + * @returns {import("@jsonbored/gittensory-engine").AcceptanceCriteria} |
| 91 | + */ |
| 92 | +export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { |
| 93 | + const promptPacket = buildPromptPacket({ |
| 94 | + taskBrief: buildTaskBrief(issue), |
| 95 | + constraints: buildConstraints(issue), |
| 96 | + feasibilityNotes: buildFeasibilityNotes(feasibility), |
| 97 | + retrievalContext: "", |
| 98 | + }); |
| 99 | + return buildAcceptanceCriteria({ promptPacket, feasibility }); |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes |
| 104 | + * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is |
| 105 | + * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. |
| 106 | + * |
| 107 | + * @param {string} workingDirectory |
| 108 | + * @param {import("@jsonbored/gittensory-engine").AcceptanceCriteria} acceptanceCriteria |
| 109 | + * @returns {{ written: boolean, path: string | null }} |
| 110 | + */ |
| 111 | +export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria) { |
| 112 | + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; |
| 113 | + const path = join(workingDirectory, ACCEPTANCE_CRITERIA_FILENAME); |
| 114 | + writeFileSync(path, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); |
| 115 | + return { written: true, path }; |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the |
| 120 | + * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it |
| 121 | + * rather than repeating its content). |
| 122 | + */ |
| 123 | +function buildInstructions(issue, acceptanceCriteriaPath) { |
| 124 | + return [ |
| 125 | + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${issue.title}`, |
| 126 | + "", |
| 127 | + (issue.body ?? "").trim(), |
| 128 | + "", |
| 129 | + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, |
| 130 | + ].join("\n"); |
| 131 | +} |
| 132 | + |
| 133 | +/** |
| 134 | + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> instructions. |
| 135 | + * Returns `ready: false` (with the computed feasibility verdict, for the caller to report) when the |
| 136 | + * verdict is `raise`/`avoid` -- the caller should abandon the attempt rather than proceed with no real |
| 137 | + * acceptance-criteria file on disk. |
| 138 | + * |
| 139 | + * @param {{ |
| 140 | + * repoFullName: string, issue: { number: number, title: string, body?: string | null, labels?: string[] }, |
| 141 | + * context: { issues: Array<{ number: number }>, pullRequests: unknown[] }, |
| 142 | + * claimLedger: { listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }, |
| 143 | + * workingDirectory: string, |
| 144 | + * }} input |
| 145 | + * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} |
| 146 | + */ |
| 147 | +export function buildCodingTaskSpec(input) { |
| 148 | + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); |
| 149 | + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); |
| 150 | + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); |
| 151 | + |
| 152 | + if (!writeResult.written) { |
| 153 | + return { ready: false, verdict: feasibility.verdict, feasibility }; |
| 154 | + } |
| 155 | + |
| 156 | + return { |
| 157 | + ready: true, |
| 158 | + verdict: feasibility.verdict, |
| 159 | + feasibility, |
| 160 | + acceptanceCriteriaPath: writeResult.path, |
| 161 | + instructions: buildInstructions(input.issue, writeResult.path), |
| 162 | + title: input.issue.title, |
| 163 | + body: input.issue.body ?? undefined, |
| 164 | + labels: input.issue.labels, |
| 165 | + linkedIssues: [input.issue.number], |
| 166 | + }; |
| 167 | +} |
0 commit comments