@uchibeke/gallivant 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +65 -5
- package/docs/config.md +35 -10
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +23 -18
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +353 -97
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// Bitbucket wire transport only. Review decisions and task state stay in the shared lifecycle.
|
|
2
|
+
import { bitbucketFeedback, bitbucketVerdict } from "./bitbucket_review.mjs";
|
|
3
|
+
import { normalizePublicBody, parseBlock, renderBlock } from "../block.mjs";
|
|
4
|
+
import { bitbucketDefinition, bitbucketPrompts } from "./bitbucket_contract.mjs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveBitbucketCredential } from "./bitbucket_credential.mjs";
|
|
7
|
+
import { repositoryIdentity, parseRemote, assertBranchName } from "../repository.mjs";
|
|
8
|
+
import { MAX_DIFF_BYTES } from "../host_contract.mjs";
|
|
9
|
+
import { assertPublicText } from "../public_output.mjs";
|
|
10
|
+
import { gitDiffSectionMatches } from "../git_diff.mjs";
|
|
11
|
+
import { inspectReviewImageSet, REVIEW_IMAGE_LIMITS } from "../review_image.mjs";
|
|
12
|
+
import { inspectReviewLink, MAX_REVIEW_LINK_BYTES } from "../review_link.mjs";
|
|
13
|
+
|
|
14
|
+
const ROOT = "https://api.bitbucket.org/2.0";
|
|
15
|
+
const source = Object.freeze({ host: "bitbucket.org", adapter: "bitbucket-pull-request" });
|
|
16
|
+
const uuid = value => typeof value === "string" && /^\{[0-9a-f-]{36}\}$/i.test(value);
|
|
17
|
+
const sha = value => typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
|
|
18
|
+
const date = value => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
19
|
+
const decodeUtf8 = buffer => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
|
|
20
|
+
const safeSourcePath = path => typeof path === "string" && !!path && !/[\\\0\r\n]/.test(path) && !path.split("/").some(p => !p || p === "." || p === ".." || p.toLowerCase() === ".git");
|
|
21
|
+
const fault = (code, message, extra = {}) => Object.assign(new Error(`Bitbucket ${message}`), { code, ...extra });
|
|
22
|
+
const incomplete = what => fault("BB_INCOMPLETE", `incomplete ${what}; preserve state and retrieve complete evidence`);
|
|
23
|
+
const actor = user => {
|
|
24
|
+
if (!uuid(user?.uuid)) throw incomplete("actor identity");
|
|
25
|
+
return { login: user.uuid.toLowerCase(), bot: user.type === "app_user", displayName: user.display_name ?? null, accountId: user.account_id ?? null };
|
|
26
|
+
};
|
|
27
|
+
function stamp(row, kind, headSha = null) {
|
|
28
|
+
if (row.id == null || !date(row.created_on) || !date(row.updated_on)) throw incomplete(`${kind} timestamps or identity`);
|
|
29
|
+
return { source, eventId: String(row.id), actor: actor(row.user ?? row.author), headSha, createdAt: row.created_on, updatedAt: row.updated_on, at: row.created_on, kind };
|
|
30
|
+
}
|
|
31
|
+
export const bitbucketBranch = assertBranchName;
|
|
32
|
+
function repositoryRecord(row, expected) {
|
|
33
|
+
if (!uuid(row?.uuid) || !uuid(row.workspace?.uuid) || typeof row.is_private !== "boolean" || row.scm !== "git" || !row.mainbranch?.name || !row.slug || !row.workspace.slug || !row.full_name) throw incomplete("repository identity");
|
|
34
|
+
const actual = repositoryIdentity(`${row.workspace.slug}/${row.slug}`, "bitbucket.org");
|
|
35
|
+
if (actual.remoteIdentity !== expected.remoteIdentity || repositoryIdentity(row.full_name, "bitbucket.org").remoteIdentity !== expected.remoteIdentity) throw fault("BB_IDENTITY", "repository renamed or transferred; inspect origin and explicitly rebind before retrying");
|
|
36
|
+
if (!Array.isArray(row.links?.clone) || !row.links.clone.length || row.links.clone.some(link => !["https", "ssh"].includes(link.name) || parseRemote(link.href).remoteIdentity !== expected.remoteIdentity)) throw fault("BB_IDENTITY", "API clone links do not match the selected repository");
|
|
37
|
+
return { ...expected, uuid: row.uuid.toLowerCase(), workspaceUuid: row.workspace.uuid.toLowerCase(), workspace: actual.path.split("/")[0], slug: actual.path.split("/")[1], private: row.is_private, defaultBranch: row.mainbranch.name,
|
|
38
|
+
archived: typeof row.is_archived === "boolean" ? row.is_archived : "unknown" };
|
|
39
|
+
}
|
|
40
|
+
function normalizePr(row, identity) {
|
|
41
|
+
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !["OPEN", "MERGED", "DECLINED", "SUPERSEDED"].includes(row.state) || typeof row.draft !== "boolean" || typeof row.title !== "string" || typeof row.description !== "string" || !Array.isArray(row.participants) || !Array.isArray(row.reviewers)) throw incomplete("pull request");
|
|
42
|
+
for (const side of [row.source, row.destination]) {
|
|
43
|
+
if (!side?.branch?.name || !sha(side.commit?.hash) || !uuid(side.repository?.uuid) || !side.repository.full_name) throw incomplete("PR branch, commit or repository");
|
|
44
|
+
}
|
|
45
|
+
if (row.destination.repository.uuid.toLowerCase() !== identity.uuid || repositoryIdentity(row.destination.repository.full_name, "bitbucket.org").path !== identity.path) throw fault("BB_IDENTITY", "PR destination identity mismatch");
|
|
46
|
+
const cross = row.source.repository.uuid.toLowerCase() !== identity.uuid || repositoryIdentity(row.source.repository.full_name, "bitbucket.org").path !== identity.path;
|
|
47
|
+
const url = bitbucketDefinition.prUrl(identity, row.id);
|
|
48
|
+
if (row.links?.html?.href !== url) throw fault("BB_IDENTITY", "PR URL identity mismatch");
|
|
49
|
+
const participants = row.participants.map(p => {
|
|
50
|
+
if (typeof p.approved !== "boolean" || !["PARTICIPANT", "REVIEWER"].includes(p.role)) throw incomplete("reviewer participant");
|
|
51
|
+
return { actor: actor(p.user), role: p.role, approved: p.approved, state: p.state ?? null, participatedAt: p.participated_on ?? null };
|
|
52
|
+
});
|
|
53
|
+
return { ...stamp(row, "pull-request", row.source.commit.hash), number: row.id, url, title: row.title, body: row.description,
|
|
54
|
+
headRefName: row.source.branch.name, baseRefName: row.destination.branch.name, headRefOid: row.source.commit.hash, baseRefOid: row.destination.commit.hash,
|
|
55
|
+
repositoryId: identity.uuid, sourceRepositoryId: row.source.repository.uuid.toLowerCase(), baseRepositoryId: identity.uuid,
|
|
56
|
+
isCrossRepository: cross, isDraft: row.draft, state: row.state === "MERGED" ? "MERGED" : row.state === "OPEN" ? "OPEN" : "CLOSED", hostState: row.state,
|
|
57
|
+
mergeCommit: sha(row.merge_commit?.hash) ? { oid: row.merge_commit.hash } : null, mergedAt: null, strictIdentity: true,
|
|
58
|
+
participants, reviewers: row.reviewers.map(actor), complete: true };
|
|
59
|
+
}
|
|
60
|
+
function normalizeComment(row, prUrl = null) {
|
|
61
|
+
if (typeof row.deleted !== "boolean" || (!row.deleted && typeof row.content?.raw !== "string")) throw incomplete("comment");
|
|
62
|
+
let headSha = bitbucketVerdict(row.content?.raw)?.headSha ?? row.content?.raw?.match(/^Reviewed commit:\s*`?([0-9a-f]{40})`?\s*$/mi)?.[1]?.toLowerCase() ?? null;
|
|
63
|
+
if (row.links?.code?.href && prUrl) {
|
|
64
|
+
let code, pr;
|
|
65
|
+
try { code = new URL(row.links.code.href); pr = new URL(prUrl); } catch { throw incomplete("inline commit link"); }
|
|
66
|
+
const match = code.pathname.match(/^\/([^/]+\/[^/]+)\/src\/([0-9a-f]{40})(?:\/|$)/i);
|
|
67
|
+
if (code.origin !== pr.origin || !match || match[1].toLowerCase() !== pr.pathname.split("/").slice(1, 3).join("/").toLowerCase()) throw incomplete("inline commit link");
|
|
68
|
+
if (headSha && headSha !== match[2].toLowerCase()) throw incomplete("conflicting comment commit");
|
|
69
|
+
headSha = match[2].toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
return { ...stamp(row, row.inline ? "inline" : "comment", headSha), headBinding: headSha || row.inline || bitbucketVerdict(row.content?.raw) ? "exact" : "cursor", url: prUrl ? `${prUrl}#comment-${row.id}` : null, id: row.id, body: row.deleted ? "" : row.content.raw, deleted: row.deleted, replyTo: row.parent?.id ?? null,
|
|
72
|
+
path: row.inline?.path ?? null, line: row.inline?.to ?? row.inline?.from ?? null, resolved: row.resolution != null };
|
|
73
|
+
}
|
|
74
|
+
function normalizeTask(row) {
|
|
75
|
+
if (!Number.isSafeInteger(row?.id) || !["RESOLVED", "UNRESOLVED"].includes(row.state) || typeof row.content?.raw !== "string" || !date(row.created_on) || !date(row.updated_on)) throw incomplete("task");
|
|
76
|
+
return { source, eventId: String(row.id), actor: actor(row.creator), headSha: null, createdAt: row.created_on, updatedAt: row.updated_on,
|
|
77
|
+
id: row.id, state: row.state, body: row.content.raw, commentId: row.comment?.id ?? null };
|
|
78
|
+
}
|
|
79
|
+
function normalizeStatus(row, head, commitUrl) {
|
|
80
|
+
// Statuses carry a commit link, not a nested commit. Bind evidence to the queried head;
|
|
81
|
+
// compare the canonical link without following it or accepting another repository.
|
|
82
|
+
const commitLink = row?.links?.commit?.href;
|
|
83
|
+
if (!row?.key || !row.name || !["SUCCESSFUL", "FAILED", "INPROGRESS", "STOPPED"].includes(row.state) || !date(row.created_on) || !date(row.updated_on) || typeof commitLink !== "string" || commitLink.toLowerCase() !== commitUrl.toLowerCase() || typeof row.url !== "string") throw incomplete("current-head build status");
|
|
84
|
+
return { source, eventId: row.key, actor: { login: "", bot: false }, headSha: head, name: row.name, state: row.state,
|
|
85
|
+
outcome: row.state === "SUCCESSFUL" ? "passed" : row.state === "INPROGRESS" ? "pending" : "failed", url: row.url, runId: null, createdAt: row.created_on, updatedAt: row.updated_on };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function makeBitbucket({ config = {}, credential, fetch: request = globalThis.fetch, limits = {} } = {}) {
|
|
89
|
+
const bound = new Map(), pending = new Set(), publishing = new Set();
|
|
90
|
+
let account = null, accountUuid = null;
|
|
91
|
+
const maxPages = limits.pages ?? 100, maxItems = limits.items ?? 10_000, maxJson = limits.jsonBytes ?? 4 * 1024 * 1024;
|
|
92
|
+
function allowed(url, prefix) {
|
|
93
|
+
let target;
|
|
94
|
+
try { target = new URL(url); } catch { throw fault("BB_TARGET", "invalid API target"); }
|
|
95
|
+
if (target.origin !== "https://api.bitbucket.org" || target.username || target.password || target.hash || !target.pathname.startsWith(`${prefix}/`) && target.pathname !== prefix) throw fault("BB_TARGET", "refused API host/repository redirect or pagination target");
|
|
96
|
+
return target;
|
|
97
|
+
}
|
|
98
|
+
async function call(url, { method = "GET", body, text = false, raw = false, prefix, diffRedirect = false, maxBytes = null } = {}) {
|
|
99
|
+
const target = allowed(url, prefix);
|
|
100
|
+
const c = await credential({ operation: "api" });
|
|
101
|
+
if (c?.host !== "bitbucket.org" || !c.token || typeof c.email !== "string" || !/^[^\s:@]+@[^\s:@]+$/.test(c.email)) throw fault("NO_CREDENTIAL", "account API token and email required for PR/comment access");
|
|
102
|
+
const binding = bound.get(c.repository);
|
|
103
|
+
const immutablePrefix = binding ? `/2.0/repositories/${encodeURIComponent(binding.workspaceUuid)}/${encodeURIComponent(binding.uuid)}` : null;
|
|
104
|
+
if (c.repository && ![`/2.0/repositories/${c.repository}`, `/2.0/user/workspaces/${c.repository.split("/")[0]}/permissions/repositories`, immutablePrefix].includes(prefix)) throw fault("BB_IDENTITY", "credential repository binding mismatch");
|
|
105
|
+
const login = c.email.toLowerCase();
|
|
106
|
+
if (account && account !== login) throw fault("BB_IDENTITY", "API account changed; explicitly rebind instead of silently changing identity");
|
|
107
|
+
account = login;
|
|
108
|
+
const auth = `Basic ${Buffer.from(`${c.email}:${c.token}`).toString("base64")}`;
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let timer;
|
|
111
|
+
const deadline = new Promise((_, reject) => {
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
controller.abort();
|
|
114
|
+
reject(fault("BB_TRANSPORT", `${method} transport failed or timed out; acceptance is unknown, read before retrying`, { uncertain: method !== "GET" }));
|
|
115
|
+
}, limits.timeoutMs ?? 20_000);
|
|
116
|
+
});
|
|
117
|
+
let response;
|
|
118
|
+
const perform = async () => {
|
|
119
|
+
try {
|
|
120
|
+
response = await request(target.href, { method, headers: { Authorization: auth, Accept: text || raw ? "text/plain" : "application/json", ...(body ? { "Content-Type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, redirect: "manual", cache: "no-store", signal: controller.signal });
|
|
121
|
+
// A transport can ignore abort and return late. Never follow a late
|
|
122
|
+
// redirect or otherwise act on that response after the deadline.
|
|
123
|
+
if (controller.signal.aborted) throw fault("BB_TRANSPORT", `${method} transport failed or timed out; acceptance is unknown, read before retrying`, { uncertain: method !== "GET" });
|
|
124
|
+
if (response.status >= 300 && response.status < 400) {
|
|
125
|
+
// The PR diff endpoint documents a redirect to the repository diff endpoint. Never follow arbitrary links.
|
|
126
|
+
const location = response.headers.get("location");
|
|
127
|
+
if (diffRedirect && [301, 302, 303, 307].includes(response.status) && location) {
|
|
128
|
+
const next = allowed(new URL(location, target).href, prefix);
|
|
129
|
+
if (!next.pathname.startsWith(`${prefix}/diff/`)) throw fault("BB_TARGET", "unexpected diff redirect");
|
|
130
|
+
return await call(next.href, { text: true, prefix, maxBytes });
|
|
131
|
+
}
|
|
132
|
+
throw fault("BB_IDENTITY", "API redirect refused; repository may have been renamed or transferred");
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok) throw fault(response.status === 429 ? "BB_RATE_LIMIT" : "BB_HTTP", `API returned HTTP ${response.status}; ${[401, 403].includes(response.status) ? "restore the selected token's repository and pull-request read/write access" : "restore API access and retry the read before writing"}`, { status: response.status, retryAfter: response.headers.get("retry-after") });
|
|
135
|
+
const max = text || raw ? (maxBytes ?? limits.diffBytes ?? MAX_DIFF_BYTES) : maxJson;
|
|
136
|
+
if (Number(response.headers.get("content-length")) >= max) { await response.body?.cancel(); throw fault("BB_OVERSIZED", "response exceeds the evidence bound"); }
|
|
137
|
+
if (!response.body) throw incomplete("response body");
|
|
138
|
+
const chunks = []; let length = 0;
|
|
139
|
+
for await (const chunk of response.body) {
|
|
140
|
+
length += chunk.byteLength;
|
|
141
|
+
if (length >= max) { controller.abort(); throw fault("BB_OVERSIZED", "response exceeds the evidence bound"); }
|
|
142
|
+
chunks.push(Buffer.from(chunk));
|
|
143
|
+
}
|
|
144
|
+
const buffer = Buffer.concat(chunks), value = buffer.toString("utf8");
|
|
145
|
+
if (raw) return buffer;
|
|
146
|
+
if (text) return value;
|
|
147
|
+
try { return JSON.parse(value); } catch { throw incomplete("JSON response"); }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (typeof error?.code === "string" && error.code.startsWith("BB_")) throw error;
|
|
150
|
+
// Transport diagnostics may contain Authorization or a token in an echoed URL. Never retain them.
|
|
151
|
+
throw fault("BB_TRANSPORT", `${method} transport failed or timed out; acceptance is unknown, read before retrying`, { uncertain: method !== "GET" });
|
|
152
|
+
} finally {
|
|
153
|
+
// Release unread error/redirect bodies too; repeated failures must not retain sockets.
|
|
154
|
+
if (response?.body && !response.body.locked) await response.body.cancel().catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// Aborting fetch does not guarantee that a transport, response stream, or
|
|
158
|
+
// cleanup promise settles. Bound the whole operation, including cleanup;
|
|
159
|
+
// a late completion is ignored and cannot authorize a write retry.
|
|
160
|
+
try { return await Promise.race([perform(), deadline]); }
|
|
161
|
+
finally { clearTimeout(timer); }
|
|
162
|
+
}
|
|
163
|
+
async function pages(url, prefix) {
|
|
164
|
+
const first = allowed(url, prefix), seen = new Set(), ids = new Set(), rows = []; let count = 0, expectedSize = null;
|
|
165
|
+
while (url) {
|
|
166
|
+
const target = allowed(url, prefix);
|
|
167
|
+
const filters = value => [...value.searchParams].filter(([key]) => !["page", "pagelen", "after", "before"].includes(key)).sort().toString();
|
|
168
|
+
if (target.pathname !== first.pathname || filters(target) !== filters(first) || seen.has(target.href) || ++count > maxPages) throw incomplete("pagination (cycle, changed query or page limit)");
|
|
169
|
+
seen.add(target.href);
|
|
170
|
+
const data = await call(target.href, { prefix });
|
|
171
|
+
if (!Array.isArray(data?.values) || (data.next != null && (typeof data.next !== "string" || !data.next)) || (data.size != null && (!Number.isSafeInteger(data.size) || data.size < 0))) throw incomplete("page");
|
|
172
|
+
if (data.size != null) {
|
|
173
|
+
if (expectedSize !== null && expectedSize !== data.size) throw fault("BB_STALE", "collection changed during pagination; retry a complete read");
|
|
174
|
+
expectedSize = data.size;
|
|
175
|
+
}
|
|
176
|
+
for (const row of data.values) {
|
|
177
|
+
const id = row?.id ?? row?.uuid ?? row?.key ?? row?.repository?.uuid;
|
|
178
|
+
if (id != null && ids.has(String(id))) throw incomplete("pagination duplicate identity");
|
|
179
|
+
if (id != null) ids.add(String(id));
|
|
180
|
+
rows.push(row);
|
|
181
|
+
}
|
|
182
|
+
if (rows.length > maxItems || (data.next && !data.values.length)) throw incomplete("collection bound");
|
|
183
|
+
url = data.next || null;
|
|
184
|
+
}
|
|
185
|
+
if (expectedSize !== null && rows.length !== expectedSize) throw incomplete("pagination size");
|
|
186
|
+
return rows;
|
|
187
|
+
}
|
|
188
|
+
const scope = (repo, identity = null) => {
|
|
189
|
+
const r = repositoryIdentity(repo, "bitbucket.org");
|
|
190
|
+
if (r.host !== "bitbucket.org") throw fault("BB_TARGET", "unsupported repository host");
|
|
191
|
+
const path = identity ? `${encodeURIComponent(identity.workspaceUuid)}/${encodeURIComponent(identity.uuid)}` : r.path;
|
|
192
|
+
return { r, prefix: `/2.0/repositories/${path}`, url: `${ROOT}/repositories/${path}` };
|
|
193
|
+
};
|
|
194
|
+
async function inspect(repo) {
|
|
195
|
+
const { r, prefix, url } = scope(repo), raw = await call(url, { prefix });
|
|
196
|
+
const result = repositoryRecord(raw, r), previous = bound.get(r.path) ?? config.git_hosts?.["bitbucket.org"]?.repositories?.[r.path];
|
|
197
|
+
if (previous?.uuid && (previous.uuid !== result.uuid || previous.workspaceUuid !== result.workspaceUuid)) throw fault("BB_IDENTITY", "immutable repository/workspace identity changed; inspect and explicitly rebind");
|
|
198
|
+
bound.set(r.path, result);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
async function branch(repo, name) {
|
|
202
|
+
bitbucketBranch(name);
|
|
203
|
+
const { url, prefix } = scope(repo), row = await call(`${url}/refs/branches/${encodeURIComponent(name)}`, { prefix });
|
|
204
|
+
if (row?.name !== name || !sha(row.target?.hash)) throw incomplete("base branch");
|
|
205
|
+
return row.target.hash;
|
|
206
|
+
}
|
|
207
|
+
async function listPr(repo, name, { allStates = false, skipForks = false } = {}) {
|
|
208
|
+
const identity = await inspect(repo), { url, prefix } = scope(repo);
|
|
209
|
+
const q = name ? `source.branch.name=${JSON.stringify(bitbucketBranch(name))}` : null;
|
|
210
|
+
const states = allStates ? "state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED" : "state=OPEN";
|
|
211
|
+
const rows = await pages(`${url}/pullrequests?pagelen=50&${states}${q ? `&q=${encodeURIComponent(q)}` : ""}`, prefix);
|
|
212
|
+
// Collections omit reviewers and participants. Read each bounded result by ID
|
|
213
|
+
// through the selected repository, never through a response-supplied URL.
|
|
214
|
+
const records = [];
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
// Repository-wide discovery cannot use a fork's commit endpoint through
|
|
217
|
+
// this repository binding. Publication still examines every matching
|
|
218
|
+
// branch, including forks, so it can refuse a conflicting PR.
|
|
219
|
+
if (skipForks && uuid(row?.source?.repository?.uuid) && row.source.repository.uuid.toLowerCase() !== identity.uuid) continue;
|
|
220
|
+
records.push(await readPr(repo, row?.id, identity));
|
|
221
|
+
}
|
|
222
|
+
return records;
|
|
223
|
+
}
|
|
224
|
+
async function readPr(repo, n, identity) {
|
|
225
|
+
if (!Number.isSafeInteger(n) || n < 1) throw incomplete("PR number");
|
|
226
|
+
const { url, prefix } = scope(repo), raw = await call(`${url}/pullrequests/${n}`, { prefix });
|
|
227
|
+
// Bitbucket's detail API abbreviates commit hashes even when the branch API
|
|
228
|
+
// returns full IDs. Resolve the PR's pinned commits, not the current branch
|
|
229
|
+
// tips: either branch may move while this snapshot is being collected.
|
|
230
|
+
const exactCommit = async abbreviated => {
|
|
231
|
+
if (sha(abbreviated)) return abbreviated;
|
|
232
|
+
if (!/^[0-9a-f]{12,39}$/.test(abbreviated ?? "")) throw incomplete("PR commit abbreviation");
|
|
233
|
+
const row = await call(`${url}/commit/${abbreviated}`, { prefix });
|
|
234
|
+
if (!sha(row?.hash) || !row.hash.startsWith(abbreviated) || !date(row.date)) throw incomplete("resolved PR commit identity/date");
|
|
235
|
+
return row.hash;
|
|
236
|
+
};
|
|
237
|
+
const expand = async side => {
|
|
238
|
+
if (!side?.branch?.name || !uuid(side.repository?.uuid) || !side.repository?.full_name) throw incomplete("PR branch or repository");
|
|
239
|
+
if (side.repository.uuid.toLowerCase() !== identity.uuid || repositoryIdentity(side.repository.full_name, "bitbucket.org").path !== identity.path) throw fault("BB_IDENTITY", "conflicting cross-repository PR cannot be bound to this checkout");
|
|
240
|
+
return { ...side, commit: { ...side.commit, hash: await exactCommit(side.commit?.hash) } };
|
|
241
|
+
};
|
|
242
|
+
const source = await expand(raw?.source), destination = await expand(raw?.destination);
|
|
243
|
+
const merge = raw?.merge_commit?.hash;
|
|
244
|
+
const mergeCommit = merge ? { ...raw.merge_commit, hash: await exactCommit(merge) } : raw?.merge_commit;
|
|
245
|
+
const normalized = normalizePr({ ...raw, source, destination, merge_commit: mergeCommit }, identity);
|
|
246
|
+
if (normalized.number !== n) throw fault("BB_IDENTITY", "PR number mismatch");
|
|
247
|
+
return normalized;
|
|
248
|
+
}
|
|
249
|
+
const fingerprint = pr => JSON.stringify([pr.eventId, pr.headRefOid, pr.baseRefOid, pr.headRefName, pr.baseRefName, pr.sourceRepositoryId, pr.baseRepositoryId, pr.updatedAt, pr.title, pr.body, pr.hostState, pr.isDraft, pr.participants, pr.mergeCommit]);
|
|
250
|
+
async function snapshot(repo, n) {
|
|
251
|
+
const identity = await inspect(repo), { url, prefix } = scope(repo), first = await readPr(repo, n, identity);
|
|
252
|
+
const comments = (await pages(`${url}/pullrequests/${n}/comments?pagelen=50`, prefix)).map(row => normalizeComment(row, first.url));
|
|
253
|
+
const tasks = (await pages(`${url}/pullrequests/${n}/tasks?pagelen=50`, prefix)).map(normalizeTask);
|
|
254
|
+
const commitUrl = `${url}/commit/${first.headSha}`;
|
|
255
|
+
const statusCheckRollup = (await pages(`${commitUrl}/statuses?pagelen=50`, prefix)).map(row => normalizeStatus(row, first.headSha, commitUrl));
|
|
256
|
+
const diff = await call(`${url}/pullrequests/${n}/diff`, { text: true, prefix, diffRedirect: true });
|
|
257
|
+
// Activity/CI can change without moving a branch. Recheck collections as well as PR metadata.
|
|
258
|
+
const finalComments = (await pages(`${url}/pullrequests/${n}/comments?pagelen=50`, prefix)).map(row => normalizeComment(row, first.url));
|
|
259
|
+
const finalTasks = (await pages(`${url}/pullrequests/${n}/tasks?pagelen=50`, prefix)).map(normalizeTask);
|
|
260
|
+
const finalStatuses = (await pages(`${commitUrl}/statuses?pagelen=50`, prefix)).map(row => normalizeStatus(row, first.headSha, commitUrl));
|
|
261
|
+
const last = await readPr(repo, n, await inspect(repo));
|
|
262
|
+
if (fingerprint(first) !== fingerprint(last) || collectionKey([comments, tasks, statusCheckRollup]) !== collectionKey([finalComments, finalTasks, finalStatuses])) throw fault("BB_STALE", "PR changed while reading evidence; discard snapshot and retry");
|
|
263
|
+
const result = { ...first, comments, tasks, statusCheckRollup, diff };
|
|
264
|
+
if (first.state === "MERGED") {
|
|
265
|
+
if (!first.mergeCommit) throw incomplete("merge commit");
|
|
266
|
+
// The merge commit's date is verifiable; updated_on may describe a later edit.
|
|
267
|
+
result.mergedAt = (await commit(repo, first.mergeCommit.oid)).date;
|
|
268
|
+
}
|
|
269
|
+
return { ...result, feedback: bitbucketFeedback(result) };
|
|
270
|
+
}
|
|
271
|
+
const collectionKey = lists => JSON.stringify(lists.map(rows => [...rows].sort((a, b) => String(a.eventId).localeCompare(String(b.eventId)))));
|
|
272
|
+
async function commit(repo, hash) {
|
|
273
|
+
if (!sha(hash)) throw incomplete("full commit SHA");
|
|
274
|
+
const { url, prefix } = scope(repo), row = await call(`${url}/commit/${hash}`, { prefix });
|
|
275
|
+
if (row?.hash !== hash || !date(row.date)) throw incomplete("commit identity/date");
|
|
276
|
+
return row;
|
|
277
|
+
}
|
|
278
|
+
async function user(repo) {
|
|
279
|
+
const identity = await inspect(repo), prefix = `/2.0/user/workspaces/${identity.workspace}/permissions/repositories`;
|
|
280
|
+
const rows = await pages(`https://api.bitbucket.org${prefix}?pagelen=50&q=${encodeURIComponent(`repository.uuid=${JSON.stringify(identity.uuid)}`)}`, prefix);
|
|
281
|
+
if (rows.length !== 1 || rows[0].repository?.uuid?.toLowerCase() !== identity.uuid) throw incomplete("authenticated repository actor");
|
|
282
|
+
const login = actor(rows[0].user).login;
|
|
283
|
+
if (accountUuid && accountUuid !== login) throw fault("BB_IDENTITY", "API actor changed; explicitly rebind before retrying");
|
|
284
|
+
accountUuid = login;
|
|
285
|
+
return login;
|
|
286
|
+
}
|
|
287
|
+
async function fileAtStrict(repo, path, ref) {
|
|
288
|
+
if (!safeSourcePath(path)) throw incomplete("repository file path");
|
|
289
|
+
await inspect(repo);
|
|
290
|
+
const hash = sha(ref) ? ref : await branch(repo, ref);
|
|
291
|
+
await commit(repo, hash); // A 404 below means a missing file, not a missing revision.
|
|
292
|
+
const { url, prefix } = scope(repo);
|
|
293
|
+
const target = `${url}/src/${hash}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
294
|
+
let metadata;
|
|
295
|
+
try { metadata = await call(`${target}?format=meta`, { prefix }); }
|
|
296
|
+
catch (e) { if (e.status === 404) return null; throw e; }
|
|
297
|
+
if (metadata?.type !== "commit_file" || metadata.path !== path || metadata.commit?.hash !== hash || !Number.isSafeInteger(metadata.size) || metadata.size < 0) throw incomplete("repository file metadata");
|
|
298
|
+
const content = await call(target, { text: true, prefix });
|
|
299
|
+
if (Buffer.byteLength(content) !== metadata.size) throw incomplete("repository file size");
|
|
300
|
+
return content;
|
|
301
|
+
}
|
|
302
|
+
async function reviewEvidence(repo, n, head, base, maxBytes, { imageEvidence = false, linkEvidence = false } = {}) {
|
|
303
|
+
if (!Number.isSafeInteger(n) || n < 1 || !sha(head) || !sha(base) || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw incomplete("review request identity or bound");
|
|
304
|
+
const identity = await inspect(repo), { url, prefix } = scope(repo, identity), first = await readPr(repo, n, identity);
|
|
305
|
+
if (first.state !== "OPEN" || first.isCrossRepository || first.repositoryId !== identity.uuid || first.sourceRepositoryId !== identity.uuid || first.headRefOid !== head || first.baseRefOid !== base) throw fault("BB_STALE", "review requires the current open same-repository PR at the exact supplied head and base");
|
|
306
|
+
// Bitbucket interprets a two-commit comparison opposite to `git diff`: the
|
|
307
|
+
// change being previewed is first. topic=true compares that head to the
|
|
308
|
+
// merge base with the destination, matching an open PR's change set.
|
|
309
|
+
const spec = `${head}..${base}`;
|
|
310
|
+
const common = await call(`${url}/merge-base/${spec}`, { prefix });
|
|
311
|
+
if (!sha(common?.hash)) throw incomplete("review merge base");
|
|
312
|
+
const rows = await pages(`${url}/diffstat/${spec}?pagelen=100&topic=true&renames=true`, prefix);
|
|
313
|
+
if (!rows.length) throw incomplete("review changed-path inventory");
|
|
314
|
+
const inventory = [], paths = new Set();
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
const status = String(row?.status || "").toLowerCase(), oldPath = row?.old?.path ?? null, newPath = row?.new?.path ?? null;
|
|
317
|
+
if (!["added", "modified", "removed", "renamed"].includes(status) ||
|
|
318
|
+
status !== "added" && (row.old?.type !== "commit_file" || typeof oldPath !== "string") ||
|
|
319
|
+
status !== "removed" && (row.new?.type !== "commit_file" || typeof newPath !== "string")) throw incomplete("review diffstat file identity");
|
|
320
|
+
const path = status === "removed" ? oldPath : newPath;
|
|
321
|
+
if (!path || paths.has(path) || [oldPath, newPath].filter(value => value != null).some(value => !safeSourcePath(value))) throw incomplete("review duplicate or unsafe changed path");
|
|
322
|
+
paths.add(path); inventory.push({ path, oldPath, newPath, status });
|
|
323
|
+
}
|
|
324
|
+
const diffBuffer = await call(`${url}/diff/${spec}?topic=true&renames=true`, { raw: true, prefix, maxBytes });
|
|
325
|
+
let diff;
|
|
326
|
+
try { diff = decodeUtf8(diffBuffer); } catch { throw incomplete("review diff UTF-8 text"); }
|
|
327
|
+
let capturedBytes = diffBuffer.length;
|
|
328
|
+
if (!diff.trim()) throw fault("BB_INCOMPLETE", "Bitbucket returned an empty review diff");
|
|
329
|
+
if (capturedBytes >= maxBytes) throw fault("BB_INCOMPLETE", "Bitbucket review diff reached its size limit and may be truncated");
|
|
330
|
+
if (!imageEvidence && /^(?:GIT binary patch|Binary files )/m.test(diff)) throw fault("BB_INCOMPLETE", "Bitbucket PR contains binary files; the text-only independent reviewer cannot inspect them. Preserve the files and arrange visual or binary review.");
|
|
331
|
+
if (/^(?:(?:old|new|new file|deleted file) mode 160000$|[ +\-]?Subproject commit )/m.test(diff) || !linkEvidence && /^(?:old|new|new file|deleted file) mode 120000$/m.test(diff)) throw fault("BB_INCOMPLETE", "Bitbucket PR contains a symlink or submodule change that the independent reviewer cannot inspect");
|
|
332
|
+
const sections = diff.split(/(?=^diff --git )/m).filter(section => section.startsWith("diff --git "));
|
|
333
|
+
if (sections.length !== inventory.length) throw incomplete("review diff changed-path inventory");
|
|
334
|
+
const unmatched = [...sections];
|
|
335
|
+
let imageCount = 0, imageBytes = 0;
|
|
336
|
+
const metadataCache = new Map();
|
|
337
|
+
const metadataFor = async (path, hash) => {
|
|
338
|
+
const target = `${url}/src/${hash}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
339
|
+
const key = `${hash}:${path}`;
|
|
340
|
+
if (!metadataCache.has(key)) metadataCache.set(key, await call(`${target}?format=meta`, { prefix }));
|
|
341
|
+
return metadataCache.get(key);
|
|
342
|
+
};
|
|
343
|
+
const read = async (path, hash, image = false, inspectLink = false) => {
|
|
344
|
+
const target = `${url}/src/${hash}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
345
|
+
const metadata = await metadataFor(path, hash);
|
|
346
|
+
if (metadata?.type !== "commit_file" || metadata.path !== path || metadata.commit?.hash !== hash || !Number.isSafeInteger(metadata.size) || metadata.size < 0 ||
|
|
347
|
+
!Array.isArray(metadata.attributes) || metadata.attributes.some(attribute => !(inspectLink ? ["link"] : image ? ["executable", "binary"] : ["executable"]).includes(attribute)) || inspectLink && !metadata.attributes.includes("link")) throw incomplete("review source metadata or regular text-file type");
|
|
348
|
+
if (inspectLink && metadata.size > MAX_REVIEW_LINK_BYTES) throw incomplete("review link target size");
|
|
349
|
+
if (image && (++imageCount > REVIEW_IMAGE_LIMITS.count || metadata.size > REVIEW_IMAGE_LIMITS.individualBytes || (imageBytes += metadata.size) > REVIEW_IMAGE_LIMITS.totalBytes)) throw fault("BB_OVERSIZED", "image review evidence exceeds the configured bounds");
|
|
350
|
+
if (capturedBytes + metadata.size >= maxBytes) throw fault("BB_OVERSIZED", "complete review evidence exceeds the configured aggregate input bound");
|
|
351
|
+
const buffer = await call(target, { raw: true, prefix, maxBytes: Math.min(maxBytes, metadata.size + 1) });
|
|
352
|
+
if (buffer.length !== metadata.size || !image && buffer.includes(0)) throw incomplete("review source size or binary content");
|
|
353
|
+
if (image || inspectLink) { capturedBytes += buffer.length; return buffer; }
|
|
354
|
+
let value;
|
|
355
|
+
try { value = decodeUtf8(buffer); } catch { throw incomplete("review source UTF-8 text"); }
|
|
356
|
+
capturedBytes += buffer.length;
|
|
357
|
+
return value;
|
|
358
|
+
};
|
|
359
|
+
const context = [];
|
|
360
|
+
const images = [];
|
|
361
|
+
for (const file of inventory) {
|
|
362
|
+
const types = linkEvidence ? await Promise.all([
|
|
363
|
+
file.status === "added" ? null : metadataFor(file.oldPath, common.hash),
|
|
364
|
+
file.status === "removed" ? null : metadataFor(file.newPath, head),
|
|
365
|
+
].filter(Boolean)) : [];
|
|
366
|
+
const linkTypes = types.map(row => Array.isArray(row?.attributes) && row.attributes.includes("link"));
|
|
367
|
+
if (linkTypes.some(Boolean) && !linkTypes.every(Boolean)) throw incomplete("review link type transition");
|
|
368
|
+
const isLink = linkTypes.some(Boolean);
|
|
369
|
+
const isImage = !isLink && imageEvidence && ([file.oldPath, file.newPath].some(path => /\.(?:png|jpe?g)$/i.test(path || "")) || unmatched.some(section => gitDiffSectionMatches(section, { ...file, binary: true }) && /^Binary files /m.test(section)));
|
|
370
|
+
const index = unmatched.findIndex(section => gitDiffSectionMatches(section, { ...file, binary: isImage }));
|
|
371
|
+
if (index < 0) throw incomplete("review diff and diffstat changed-path mismatch");
|
|
372
|
+
const [patch] = unmatched.splice(index, 1);
|
|
373
|
+
if (isLink) {
|
|
374
|
+
if (/^(?:old|new|new file|deleted file) mode (?!120000)/m.test(patch)) throw incomplete("review link type transition");
|
|
375
|
+
const side = async (path, revision, label) => inspectReviewLink(await read(path, revision, false, true), { path, revision, side: label });
|
|
376
|
+
const beforeLink = file.status === "added" ? null : await side(file.oldPath, common.hash, "before");
|
|
377
|
+
const afterLink = file.status === "removed" ? null : await side(file.newPath, head, "after");
|
|
378
|
+
context.push({ path: file.path, status: file.status, patch, kind: "symlink",
|
|
379
|
+
before: beforeLink?.target ?? null, after: afterLink?.target ?? null, beforeLink, afterLink });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (/^(?:old|new|new file|deleted file) mode 120000$/m.test(patch)) throw incomplete("review link metadata differs from diff");
|
|
383
|
+
if (isImage) {
|
|
384
|
+
const before = file.status === "added" ? null : await read(file.oldPath, common.hash, true);
|
|
385
|
+
const after = file.status === "removed" ? null : await read(file.newPath, head, true);
|
|
386
|
+
const target = { path: file.path, status: file.status, patch, kind: "image", before: null, after: null };
|
|
387
|
+
const sides = [before && { bytes: before, target, identity: { path: file.oldPath, side: "before", revision: common.hash } }, after && { bytes: after, target, identity: { path: file.newPath, side: "after", revision: head } }].filter(Boolean);
|
|
388
|
+
images.push(...sides);
|
|
389
|
+
context.push(target);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
context.push({ path: file.path, status: file.status, patch,
|
|
393
|
+
before: file.status === "added" ? null : await read(file.oldPath, common.hash),
|
|
394
|
+
after: file.status === "removed" ? null : await read(file.newPath, head) });
|
|
395
|
+
}
|
|
396
|
+
if (imageEvidence) for (const [index, verified] of inspectReviewImageSet(images, { head, mergeBase: common.hash }).entries()) {
|
|
397
|
+
const image = images[index]; image.target[image.identity.side] = { ...verified, content: image.bytes.toString("base64") };
|
|
398
|
+
}
|
|
399
|
+
if (unmatched.length) throw incomplete("review unclaimed diff patch");
|
|
400
|
+
const last = await readPr(repo, n, await inspect(repo));
|
|
401
|
+
if (fingerprint(first) !== fingerprint(last)) throw fault("BB_STALE", "PR changed while reading review evidence; discard every piece and retry");
|
|
402
|
+
return { complete: true, repositoryId: identity.uuid, pr: n, head, base, mergeBase: common.hash, diff, context };
|
|
403
|
+
}
|
|
404
|
+
async function preflight(repo, { base = null } = {}) {
|
|
405
|
+
const identity = await inspect(repo), { url, prefix } = scope(repo);
|
|
406
|
+
if (identity.archived === true) throw fault("BB_PERMISSION", "repository is archived; select a writable repository");
|
|
407
|
+
const approvedBase = bitbucketBranch(base ?? identity.defaultBranch), baseSha = await branch(repo, approvedBase);
|
|
408
|
+
// The /user endpoint returns only the authenticated account's effective role.
|
|
409
|
+
// The workspace-wide member endpoint requires administration and cannot prove this account's access.
|
|
410
|
+
const permissionPrefix = `/2.0/user/workspaces/${identity.workspace}/permissions/repositories`;
|
|
411
|
+
const permissions = await pages(`${ROOT}/user/workspaces/${identity.workspace}/permissions/repositories?pagelen=50&q=${encodeURIComponent(`repository.uuid=${JSON.stringify(identity.uuid)}`)}`, permissionPrefix);
|
|
412
|
+
if (permissions.length > 1 || permissions.some(p => !uuid(p?.repository?.uuid) || p.repository.uuid.toLowerCase() !== identity.uuid || !uuid(p.user?.uuid) || !["read", "write", "admin"].includes(p.permission))) throw incomplete("repository permissions");
|
|
413
|
+
const permission = permissions[0];
|
|
414
|
+
if (permission) {
|
|
415
|
+
const userUuid = permission.user.uuid.toLowerCase();
|
|
416
|
+
if (accountUuid && accountUuid !== userUuid) throw fault("BB_IDENTITY", "API account changed; explicitly rebind instead of silently changing identity");
|
|
417
|
+
accountUuid = userUuid;
|
|
418
|
+
}
|
|
419
|
+
if (!permission || !["write", "admin"].includes(permission.permission)) throw fault("BB_PERMISSION", "selected API account has no repository push access; grant repository write access before starting a task");
|
|
420
|
+
const prs = await pages(`${url}/pullrequests?pagelen=50&state=OPEN`, prefix);
|
|
421
|
+
let commentRead = "unknown";
|
|
422
|
+
// A fork PR may have a source commit that the selected repository cannot
|
|
423
|
+
// resolve. Only sample a same-repository PR for comment-read evidence.
|
|
424
|
+
const sample = prs.find(row => row?.source?.repository?.uuid?.toLowerCase() === identity.uuid);
|
|
425
|
+
if (sample) {
|
|
426
|
+
const pr = await readPr(repo, sample.id, identity);
|
|
427
|
+
(await pages(`${url}/pullrequests/${pr.number}/comments?pagelen=50`, prefix)).map(row => normalizeComment(row, pr.url));
|
|
428
|
+
commentRead = "proved";
|
|
429
|
+
}
|
|
430
|
+
let restrictions;
|
|
431
|
+
try {
|
|
432
|
+
restrictions = await pages(`${url}/branch-restrictions?pagelen=50`, prefix);
|
|
433
|
+
if (restrictions.some(r => !Number.isSafeInteger(r.id) || typeof r.kind !== "string" || typeof r.pattern !== "string")) throw incomplete("branch restrictions");
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error.status !== 403) throw error;
|
|
436
|
+
restrictions = "unknown"; // Not all non-admin tokens can inspect restrictions. Do not claim they are absent.
|
|
437
|
+
}
|
|
438
|
+
return { identity, base: approvedBase, baseSha, repositoryRead: "proved", accountPush: "proved", pullRequestRead: "proved", commentRead,
|
|
439
|
+
gitRead: "unknown", gitPush: "unknown", apiRepositoryWrite: "unknown", pullRequestCreate: "unknown", commentWrite: "unknown", archived: identity.archived, branchRestrictions: restrictions,
|
|
440
|
+
unknownAtWrite: ["Git push", "repository token write scope", "PR creation", "comment creation", ...(identity.archived === "unknown" ? ["archive state (not exposed)"] : [])] };
|
|
441
|
+
}
|
|
442
|
+
async function publish({ repo, branch: head, base, title, body, expectedNumber = null, expectedPreviousText = null, expectAbsent = false }) {
|
|
443
|
+
if (typeof title !== "string" || typeof body !== "string") throw incomplete("public title/body text");
|
|
444
|
+
if (typeof expectAbsent !== "boolean" || expectedPreviousText !== null && (typeof expectedPreviousText?.title !== "string" || typeof expectedPreviousText?.body !== "string") || expectAbsent && (expectedNumber !== null || expectedPreviousText !== null)) throw incomplete("PR publication preconditions");
|
|
445
|
+
// Bitbucket strips trailing newlines from PR descriptions. Compare and
|
|
446
|
+
// publish the same canonical text so an accepted write is not mistaken for
|
|
447
|
+
// an unresolved write on every subsequent read.
|
|
448
|
+
title = title.trim(); body = body.trimEnd();
|
|
449
|
+
assertPublicText(title, "PR title"); assertPublicText(body, "PR body"); bitbucketBranch(head); bitbucketBranch(base);
|
|
450
|
+
if (!title || head === base) throw fault("BB_PR_CONFLICT", "PR requires a title and separate source/base branches");
|
|
451
|
+
const identity = await inspect(repo), { url, prefix } = scope(repo, identity), key = `${identity.uuid}/${head}/${base}`;
|
|
452
|
+
await branch(repo, head); await branch(repo, base);
|
|
453
|
+
const reconcile = async () => {
|
|
454
|
+
const rows = await listPr(repo, head, { allStates: true });
|
|
455
|
+
if (rows.length > 1 || rows.some(p => p.isCrossRepository || p.repositoryId !== identity.uuid || p.headRefName !== head || p.baseRefName !== base || p.state !== "OPEN")) throw fault("BB_PR_CONFLICT", "conflicting existing PR; inspect branch/base and repository ownership before retrying");
|
|
456
|
+
return rows[0] ?? null;
|
|
457
|
+
};
|
|
458
|
+
const existing = await reconcile();
|
|
459
|
+
// Optional operator-plan preconditions. Check the final reconciliation read,
|
|
460
|
+
// immediately before deciding to write, rather than trusting an earlier plan.
|
|
461
|
+
// The API has no atomic CAS contract here: a later racing edit remains a
|
|
462
|
+
// read/write limitation, not a guarantee supplied by this client check.
|
|
463
|
+
if (expectAbsent && existing) throw fault("BB_PR_CONFLICT", "PR appeared after approval; inspect a new promotion plan before writing");
|
|
464
|
+
if (expectedNumber !== null && existing?.number !== expectedNumber) throw fault("BB_PR_CONFLICT", "selected PR no longer matches the open branch/base; inspect before writing");
|
|
465
|
+
if (expectedPreviousText !== null && (!existing || existing.title !== expectedPreviousText.title || existing.body !== expectedPreviousText.body)) throw fault("BB_PR_CONFLICT", "PR text changed after approval; inspect a new promotion plan before writing");
|
|
466
|
+
if (!existing && pending.has(key)) throw fault("BB_WRITE_UNKNOWN", "previous PR write remains unresolved; inspect the repository before another create");
|
|
467
|
+
if (existing?.title === title && existing.body === body) { pending.delete(key); return existing; }
|
|
468
|
+
const payload = existing ? { title, description: body } : { title, description: body, source: { branch: { name: head }, repository: { uuid: identity.uuid } }, destination: { branch: { name: base }, repository: { uuid: identity.uuid } }, close_source_branch: false };
|
|
469
|
+
let failure;
|
|
470
|
+
try { await call(`${url}/pullrequests${existing ? `/${existing.number}` : ""}`, { method: existing ? "PUT" : "POST", body: payload, prefix }); }
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (!error.uncertain && !(error.status >= 500) && !["BB_INCOMPLETE", "BB_OVERSIZED"].includes(error.code)) throw error;
|
|
473
|
+
failure = error;
|
|
474
|
+
}
|
|
475
|
+
// A successful write is unresolved until every reconciliation read succeeds.
|
|
476
|
+
// Preserve this marker if a list or detail read fails after the server accepted it.
|
|
477
|
+
pending.add(key);
|
|
478
|
+
const accepted = await reconcile(); // Always read after a write, including an accepted write whose response was lost.
|
|
479
|
+
if (!accepted || accepted.title !== title || accepted.body !== body || existing && accepted.number !== existing.number) {
|
|
480
|
+
pending.add(key); throw failure ?? fault("BB_WRITE_UNKNOWN", "PR write acceptance unresolved; read before retrying");
|
|
481
|
+
}
|
|
482
|
+
pending.delete(key);
|
|
483
|
+
return accepted;
|
|
484
|
+
}
|
|
485
|
+
async function publishResultPr(options) {
|
|
486
|
+
const key = scope(options.repo).r.remoteIdentity;
|
|
487
|
+
if (publishing.has(key)) throw fault("BB_BUSY", "repository publication already in progress; read before retrying");
|
|
488
|
+
publishing.add(key);
|
|
489
|
+
try { return await publish(options); } finally { publishing.delete(key); }
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
inspectRepository: inspect, preflight, publishResultPr,
|
|
493
|
+
user: async () => { const c = await credential({ operation: "api" }); if (!c.repository) throw incomplete("credential repository binding"); return user(c.repository); },
|
|
494
|
+
commitDate: async (repo, hash) => { await inspect(repo); return (await commit(repo, hash)).date; },
|
|
495
|
+
fileAt: fileAtStrict, fileAtStrict,
|
|
496
|
+
containsCommit: async (repo, ref, hash) => {
|
|
497
|
+
await inspect(repo); await commit(repo, hash);
|
|
498
|
+
const head = sha(ref) ? ref : await branch(repo, ref), { url, prefix } = scope(repo);
|
|
499
|
+
const common = await call(`${url}/merge-base/${hash}..${head}`, { prefix });
|
|
500
|
+
if (!sha(common?.hash)) throw incomplete("merge ancestry");
|
|
501
|
+
return common.hash === hash;
|
|
502
|
+
},
|
|
503
|
+
repoInfo: async (_dir, repo) => { const r = await inspect(repo); return { nameWithOwner: r.path, defaultBranch: r.defaultBranch, repository: r }; },
|
|
504
|
+
repoDefaultBranch: async repo => (await inspect(repo)).defaultBranch,
|
|
505
|
+
repoPermissions: async repo => preflight(repo),
|
|
506
|
+
branchExists: async (repo, name) => { await inspect(repo); try { await branch(repo, name); return true; } catch (e) { if (e.status === 404) return false; throw e; } },
|
|
507
|
+
refSha: async (repo, name) => { await inspect(repo); return branch(repo, name); },
|
|
508
|
+
prView: snapshot, prDiff: async (repo, n) => (await snapshot(repo, n)).diff, reviewEvidence,
|
|
509
|
+
prListByHead: listPr,
|
|
510
|
+
prListMine: async repo => { const login = await user(repo); return (await listPr(repo, null, { skipForks: true })).filter(pr => pr.actor.login === login); },
|
|
511
|
+
reviews: async (repo, n) => (await snapshot(repo, n)).feedback.reviews,
|
|
512
|
+
async setRecoveryMetadata(repo, n, identity, { taskId = null } = {}) {
|
|
513
|
+
const pr = await snapshot(repo, n);
|
|
514
|
+
if (pr.isCrossRepository || identity.branch !== pr.headRefName) throw fault("BB_IDENTITY", "recovery branch differs from PR head");
|
|
515
|
+
const validated = parseBlock(renderBlock(identity));
|
|
516
|
+
if (!validated.block) throw fault("BB_IDENTITY", "invalid recovery identity");
|
|
517
|
+
const body = normalizePublicBody(pr.body, { identity: validated.block, taskId }).trimEnd();
|
|
518
|
+
await publishResultPr({ repo, branch: pr.headRefName, base: pr.baseRefName, title: pr.title, body, expectedNumber: n });
|
|
519
|
+
const observed = await snapshot(repo, n);
|
|
520
|
+
if (observed.headRefName !== pr.headRefName || observed.baseRefName !== pr.baseRefName || observed.headRefOid !== pr.headRefOid || observed.body !== body) throw fault("BB_STALE", "PR changed during recovery metadata publication; reconcile before continuing");
|
|
521
|
+
return observed;
|
|
522
|
+
},
|
|
523
|
+
async markReady(repo, n) {
|
|
524
|
+
const identity = await inspect(repo), pr = await readPr(repo, n, identity), { url, prefix } = scope(repo, identity);
|
|
525
|
+
if (pr.isCrossRepository || pr.state !== "OPEN") throw fault("BB_PR_CONFLICT", "cannot mark this PR ready");
|
|
526
|
+
if (!pr.isDraft) return pr;
|
|
527
|
+
await call(`${url}/pullrequests/${n}`, { method: "PUT", body: { draft: false }, prefix });
|
|
528
|
+
const observed = await snapshot(repo, n);
|
|
529
|
+
if (observed.isDraft) throw fault("BB_WRITE_UNKNOWN", "regular PR conversion not observed");
|
|
530
|
+
return observed;
|
|
531
|
+
},
|
|
532
|
+
inlineComments: async (repo, n) => (await snapshot(repo, n)).comments.filter(c => c.path),
|
|
533
|
+
issueComments: async (repo, n) => (await snapshot(repo, n)).comments.filter(c => !c.path),
|
|
534
|
+
async comment(repo, n, body, { replyTo = null } = {}) {
|
|
535
|
+
if (typeof body !== "string") throw incomplete("public comment text");
|
|
536
|
+
assertPublicText(body, "PR comment");
|
|
537
|
+
const identity = await inspect(repo), pr = await readPr(repo, n, identity), { url, prefix } = scope(repo, identity);
|
|
538
|
+
if (pr.isCrossRepository || pr.state !== "OPEN") throw fault("BB_PR_CONFLICT", "comment writes require an open same-repository PR");
|
|
539
|
+
if (replyTo !== null) {
|
|
540
|
+
if (!Number.isSafeInteger(replyTo) || replyTo < 1) throw incomplete("reply parent");
|
|
541
|
+
const parent = await call(`${scope(repo).url}/pullrequests/${n}/comments/${replyTo}`, { prefix: scope(repo).prefix });
|
|
542
|
+
if (parent.id !== replyTo || parent.deleted) throw incomplete("reply parent");
|
|
543
|
+
}
|
|
544
|
+
// Never retry a comment automatically: a timeout may already have published it.
|
|
545
|
+
return normalizeComment(await call(`${url}/pullrequests/${n}/comments`, { method: "POST", body: { content: { raw: body }, ...(replyTo ? { parent: { id: replyTo } } : {}) }, prefix }), pr.url);
|
|
546
|
+
},
|
|
547
|
+
async setBody(repo, n, body) { assertPublicText(body, "PR body"); const pr = await snapshot(repo, n); if (pr.isCrossRepository) throw fault("BB_PR_CONFLICT", "fork PR writes are unsupported"); return publishResultPr({ repo, branch: pr.headRefName, base: pr.baseRefName, title: pr.title, body, expectedNumber: n }); },
|
|
548
|
+
async setTitle(repo, n, title) { assertPublicText(title, "PR title"); const pr = await snapshot(repo, n); if (pr.isCrossRepository) throw fault("BB_PR_CONFLICT", "fork PR writes are unsupported"); return publishResultPr({ repo, branch: pr.headRefName, base: pr.baseRefName, title, body: pr.body, expectedNumber: n }); },
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
552
|
+
const taskPr = `${quote(process.execPath)} ${quote(fileURLToPath(new URL("../../bin/task-pr.mjs", import.meta.url)))}`;
|
|
553
|
+
export const bitbucket = Object.freeze({ ...bitbucketDefinition, prompts: bitbucketPrompts(taskPr), credential: resolveBitbucketCredential, create: makeBitbucket });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { reviewPolicyProblems } from "../review_policy_contract.mjs";
|
|
2
|
+
import { repositoryIdentity, parsePullRequestUrl } from "../repository.mjs";
|
|
3
|
+
|
|
4
|
+
export function parseBitbucketPr(url) {
|
|
5
|
+
try {
|
|
6
|
+
const { host, pathname } = parsePullRequestUrl(url);
|
|
7
|
+
const m = pathname.match(/^([^/]+\/[^/]+)\/pull-requests\/([1-9]\d*)\/?$/);
|
|
8
|
+
if (host !== "bitbucket.org" || !m || !Number.isSafeInteger(Number(m[2]))) return null;
|
|
9
|
+
return { repo: repositoryIdentity(m[1], host).key, number: Number(m[2]) };
|
|
10
|
+
} catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
13
|
+
export const bitbucketPrompts = (taskPr = "task-pr") => ({
|
|
14
|
+
create: ({ base, branch }) => `${taskPr} create --base ${quote(base)} --head ${quote(branch)} --title-file <title-file> --body-file <body-file>`,
|
|
15
|
+
inspect: ({ pr }) => `Run \`${taskPr} view ${pr}\` and \`${taskPr} diff ${pr}\` before editing. Use \`${taskPr} comment ${pr} --reply-to <comment-id> --body-file <reply-file>\` for inline replies.`,
|
|
16
|
+
check: ({ check }) => `. Inspect the build log at ${check.url || "the PR checks page"}.`,
|
|
17
|
+
editReceipt: `Use \`${taskPr} edit <number> --body-file <receipt-file>\` for the PR body and \`${taskPr} comment <number> --reply-to <comment-id> --body-file <reply-file>\` for thread replies. Do not push an empty commit.`,
|
|
18
|
+
});
|
|
19
|
+
export const BITBUCKET_REVIEW_POLICY = Object.freeze({ actorPattern: "^\\{[0-9a-f-]{36}\\}$", explicitVerdict: true });
|
|
20
|
+
export const bitbucketDefinition = Object.freeze({
|
|
21
|
+
host: "bitbucket.org", id: "bitbucket-pull-request", dataContract: "host-records-v1",
|
|
22
|
+
dispatch: true,
|
|
23
|
+
reviewPolicy: BITBUCKET_REVIEW_POLICY,
|
|
24
|
+
capabilities: Object.freeze({ pullRequests: true, comments: true, diff: true, reviews: true, checks: true, mergeProof: true, recovery: true, labels: false }),
|
|
25
|
+
features: Object.freeze({ learning: false, rulesets: false, repositoryScopes: false, transportSetup: true, strictIdentity: true }),
|
|
26
|
+
validateConfig: settings => {
|
|
27
|
+
const errors = [];
|
|
28
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return ["Bitbucket host settings must be an object"];
|
|
29
|
+
if (Object.keys(settings).some(k => !["api", "git", "repositories", "reviewer", "trusted_authors", "required_checks"].includes(k))) errors.push("Unknown Bitbucket host setting; use api, git, repositories or explicit review policy");
|
|
30
|
+
if (settings.api && (typeof settings.api !== "object" || Object.keys(settings.api).some(k => !["email", "token_file", "scopes"].includes(k)) || typeof settings.api.email !== "string" || typeof settings.api.token_file !== "string" || settings.api.scopes != null && (!Array.isArray(settings.api.scopes) || settings.api.scopes.some(s => typeof s !== "string")))) errors.push("Bitbucket api requires email and token_file; optional scopes is an array. Store secrets only in the private token file");
|
|
31
|
+
if (settings.git && (typeof settings.git !== "object" || !["ssh", "https"].includes(settings.git.transport) || Object.keys(settings.git).some(k => !["transport", "ssh_key", "known_hosts", "token_file"].includes(k)) || ["ssh_key", "known_hosts", "token_file"].some(k => settings.git[k] != null && typeof settings.git[k] !== "string"))) errors.push("Bitbucket git requires transport ssh or https and explicit credential file paths");
|
|
32
|
+
if (settings.repositories != null) {
|
|
33
|
+
if (typeof settings.repositories !== "object" || Array.isArray(settings.repositories)) errors.push("Bitbucket repositories must map canonical paths to UUID bindings");
|
|
34
|
+
else for (const [path, binding] of Object.entries(settings.repositories)) {
|
|
35
|
+
try { if (repositoryIdentity(path, "bitbucket.org").path !== path || !binding || typeof binding !== "object" || Array.isArray(binding) || ((binding.uuid != null || binding.workspaceUuid != null) && (!/^\{[0-9a-f-]{36}\}$/.test(binding.uuid) || !/^\{[0-9a-f-]{36}\}$/.test(binding.workspaceUuid)))) throw new Error(); }
|
|
36
|
+
catch { errors.push("Invalid Bitbucket repository/workspace UUID binding; inspect before rebinding"); }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
errors.push(...reviewPolicyProblems(settings, BITBUCKET_REVIEW_POLICY));
|
|
40
|
+
if (settings.repositories && typeof settings.repositories === "object" && !Array.isArray(settings.repositories)) for (const binding of Object.values(settings.repositories)) {
|
|
41
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding)) continue;
|
|
42
|
+
errors.push(...reviewPolicyProblems(binding, BITBUCKET_REVIEW_POLICY));
|
|
43
|
+
const allowed = ["uuid", "workspaceUuid", "api", "git", "reviewer", "trusted_authors", "required_checks"];
|
|
44
|
+
if (Object.keys(binding).some(k => !allowed.includes(k))) errors.push("Unknown Bitbucket repository setting");
|
|
45
|
+
if (binding.api || binding.git) errors.push(...bitbucketDefinition.validateConfig({ ...(binding.api ? { api: binding.api } : {}), ...(binding.git ? { git: binding.git } : {}) }));
|
|
46
|
+
}
|
|
47
|
+
return errors;
|
|
48
|
+
},
|
|
49
|
+
remote: (repository, config = {}) => {
|
|
50
|
+
const protocol = (config.git_hosts?.["bitbucket.org"]?.repositories?.[repository.path]?.git ?? config.git_hosts?.["bitbucket.org"]?.git)?.transport || "https";
|
|
51
|
+
return { protocol, host: "bitbucket.org", path: repository.path,
|
|
52
|
+
url: protocol === "ssh" ? `git@ssh.bitbucket.org:${repository.path}.git` : `https://bitbucket.org/${repository.path}.git` };
|
|
53
|
+
},
|
|
54
|
+
parsePr: parseBitbucketPr,
|
|
55
|
+
prUrl: (r, n) => `https://bitbucket.org/${r.path}/pull-requests/${n}`,
|
|
56
|
+
prompts: bitbucketPrompts(),
|
|
57
|
+
});
|