[Bug] Bitbucket async merge task is discarded before its result can be read #35906
Send to my agentSummary#
A Bitbucket merge that is accepted for asynchronous processing returns successfully from the adapter without retaining its task-status link or checking its result. A mocked HTTP reproduction shows that only the merge request is sent, so a later task failure cannot be reported through that operation. The merge should retain an observable pending operation or report its eventual result.
Before submitting#
- Related issues: none found for discarded asynchronous Bitbucket merge tasks in open and closed issues or PRs; related merge-reply work is listed under Additional context.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Observed behavior#
At the Bitbucket library entry point, BitbucketPullRequestApi.make followed by runAction({ repository: "fixture/work", number: 7, action: "merge" }) sends the merge POST and returns a successful undefined result when mocked Cloud REST 2.0 responds with HTTP 202, an empty body, and a valid Location task-status URL. The HTTP fixture also serves a terminal HTTP 400 error at that task URL, but no GET reaches it. The synchronous HTTP 200 control returns a decoded PR with state: "merged".
Reproduction#
- Save the script below as
repro-async-merge.mjsoutside a T3 Code checkout. - Run
node repro-async-merge.mjs /path/to/t3codeagainst the commit in Version. - Observe the passing synchronous control and the asynchronous success with zero task-status requests.
The exact final script reproduced this deterministically in one run. Earlier harness-development attempts were inconclusive because the control inspected Result.value instead of Result.success; those attempts did not establish product behavior.
Prerequisites are Node with native TypeScript support, npm, Git, network access to install the repository-pinned effect@4.1.3, and a clean checkout of the stated commit. The script prints tool versions, creates its dependency, HOME, XDG, temporary, and T3 state directories in a disposable scratch directory, and removes them on exit. It reads the checkout through its package exports and injects an HTTP client and unused service mocks; no Bitbucket request or account access occurs. Exit 1 means the asynchronous receipt was discarded, exit 0 means the terminal fixture failure reached the operation after a task-status read, and exit 2 means setup or the synchronous control was inconclusive. A design that exposes a durable pending-operation handle instead of awaiting the terminal result would need a corresponding acceptance check.
Reproduction script: repro-async-merge.mjs
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { registerHooks } from "node:module";
import { execFileSync } from "node:child_process";
const scratch = mkdtempSync(path.join(tmpdir(), "bitbucket-repro-"));
let status = 2;
let hooks;
try {
if (!process.argv[2]) throw new Error("Pass the T3 Code checkout as the argument.");
const root = realpathSync(process.argv[2]);
for (const name of ["home", "tmp", "config", "data", "state"]) mkdirSync(path.join(scratch, name));
const env = { ...process.env, HOME: path.join(scratch, "home"), TMPDIR: path.join(scratch, "tmp"), XDG_CONFIG_HOME: path.join(scratch, "config"), XDG_DATA_HOME: path.join(scratch, "data"), XDG_STATE_HOME: path.join(scratch, "state"), T3CODE_HOME: path.join(scratch, "state"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
Object.assign(process.env, env);
console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
console.log(execFileSync("git", ["--version"], { env, encoding: "utf8" }).trim());
console.log(`commit ${execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { env, encoding: "utf8" }).trim()}`);
console.log(`npm ${execFileSync("npm", ["--version"], { env, encoding: "utf8" }).trim()}`);
execFileSync("npm", ["install", "--prefix", scratch, "--ignore-scripts", "--no-save", "--package-lock=false", "--no-audit", "--no-fund", "effect@4.1.3"], { env, stdio: "pipe" });
const effectRoot = realpathSync(path.join(scratch, "node_modules/effect"));
const effectPackage = JSON.parse(readFileSync(path.join(effectRoot, "package.json"), "utf8"));
console.log(`Effect ${effectPackage.version}`);
hooks = registerHooks({ resolve(specifier, context, next) {
if (specifier === "effect" || specifier.startsWith("effect/")) {
const subpath = specifier === "effect" ? "." : `./${specifier.slice(7)}`;
const target = effectPackage.exports[subpath] ?? effectPackage.exports["./*"].replace("*", specifier.slice(7));
return { url: pathToFileURL(path.join(effectRoot, target)).href, shortCircuit: true };
}
if (specifier.startsWith("@t3tools/")) {
const [, name, ...rest] = specifier.split("/");
const directory = path.join(root, "packages", name);
const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8"));
const target = manifest.exports[rest.length ? `./${rest.join("/")}` : "."];
return { url: pathToFileURL(path.join(directory, typeof target === "string" ? target : target.import)).href, shortCircuit: true };
}
return next(specifier, context);
} });
const Effect = await import("effect/Effect");
const Layer = await import("effect/Layer");
const Result = await import("effect/Result");
const ConfigProvider = await import("effect/ConfigProvider");
const FileSystem = await import("effect/FileSystem");
const { HttpClient, HttpClientResponse } = await import("effect/http");
const { DEFAULT_SERVER_SETTINGS } = await import("@t3tools/contracts");
const Host = await import("@t3tools/source-control-core/server/SourceControlHost");
const Limits = await import("@t3tools/source-control-core/server/SourceControlRateLimit");
const B = await import(pathToFileURL(path.join(root, "packages/source-control-bitbucket/src/server/BitbucketApi.ts")).href);
const P = await import(pathToFileURL(path.join(root, "packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts")).href);
const base = "https://api.bitbucket.org/2.0/repositories/fixture/work/pullrequests/7";
const task = `${base}/merge/task-status/fixture-task`;
const merged = { id: 7, title: "Fixture PR", state: "MERGED", created_on: "2000-01-01T00:00:00Z", updated_on: "2000-01-01T00:00:00Z", source: { branch: { name: "feature" } }, destination: { branch: { name: "main" } }, links: { html: { href: "https://bitbucket.org/fixture/work/pull-requests/7" } } };
async function run(asynchronous) {
const calls = [];
const client = HttpClient.make((request) => {
calls.push(`${request.method} ${request.url}`);
let response;
if (request.method === "POST" && request.url === `${base}/merge`) {
response = asynchronous ? new Response("", { status: 202, headers: { Location: task } }) : Response.json(merged);
} else if (request.method === "GET" && request.url === task) {
response = Response.json({ type: "error", error: { message: "Fixture merge rejected" } }, { status: 400 });
} else throw new Error("Unexpected HTTP request in fixture.");
return Effect.succeed(HttpClientResponse.fromWeb(request, response));
});
const layer = B.layer.pipe(
Layer.provide(Limits.layer),
Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
Layer.provide(Layer.mock(FileSystem.FileSystem)({})),
Layer.provide(Layer.mock(Host.SourceControlHost)({ settings: { get: Effect.succeed(DEFAULT_SERVER_SETTINGS), getWithSecrets: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), update: () => Effect.die("Unexpected settings write") } })),
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
);
const outcome = await Effect.runPromise(Effect.gen(function* () {
const api = yield* P.make;
return yield* api.runAction({ repository: "fixture/work", number: 7, action: "merge" });
}).pipe(Effect.provide(layer), Effect.result));
return { outcome, calls };
}
const control = await run(false);
if (!Result.isSuccess(control.outcome) || control.outcome.success?.state !== "merged") throw new Error("Synchronous merge control failed.");
console.log("control: HTTP 200 returned state=merged");
const observed = await run(true);
console.log(`async: result=${observed.outcome._tag}; value=${Result.isSuccess(observed.outcome) ? String(observed.outcome.success) : observed.outcome.failure._tag}`);
console.log(`async: task-status requests=${observed.calls.filter((call) => call === `GET ${task}`).length}`);
console.log(`async: HTTP calls=${observed.calls.join(", ")}`);
status = Result.isSuccess(observed.outcome) ? 1 : observed.calls.includes(`GET ${task}`) ? 0 : 2;
} catch (error) {
console.log(`setup: ${error instanceof Error ? error.message.split("\n")[0] : "failed"}`);
} finally {
hooks?.deregister();
rmSync(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? "RESULT: expected behavior" : status === 1 ? "RESULT: defect reproduced" : "RESULT: inconclusive");
process.exitCode = status;
Expected behavior#
The accepted asynchronous operation must remain observable until completion or failure, rather than losing its only task receipt. Bitbucket Cloud REST 2.0 documents HTTP 202 with the polling URL in Location even when the default synchronous merge exceeds its timeout; merge queues also use this response. Its task-status endpoint distinguishes pending, successful, and failed merges. These are documented in the merge operation and task-status reference and the published OpenAPI, under the merge operation's 202 response and async parameter plus the task-status operation. The UI currently says “Merge requested”; this report does not claim that the UI falsely says “Merged”.
Impact and scope#
Impact: P2 - The asynchronous Bitbucket merge path drops the operation receipt and cannot deliver its terminal failure to the action caller; the synchronous path works in the control.
Affected inputs are merges that receive HTTP 202, including long-running merges and queue admission. The reproduction establishes the adapter's receipt loss, not a live failed merge, account-specific frequency, or data loss. A user can inspect the PR and complete the merge in Bitbucket's web interface; that workaround is documented by Bitbucket but was not exercised with a real account here. NOT_ENOUGH_INFO: the frequency of asynchronous replies in deployed repositories and the complete visible behavior on each client require authorized live-account testing.
Version#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. The dependency catalog pins Effect 4.1.3, which the reproduction used without changing the checkout.
Runtime and platform#
Node v26.12.1, Darwin arm64, Effect 4.1.3. The reproduction's setup used npm 11.20.0 and Git 2.56.0. NOT_ENOUGH_INFO: other operating systems were not exercised; running the portable script there would establish their behavior.
Interface#
Library: BitbucketPullRequestApi.runAction, reached through the Bitbucket PR provider used by the server's pullRequests.runAction RPC.
Earliest failure stage#
Integration or transport
Relevant output or evidence#
Actual output from the exact final script, with no redactions needed because all repository names and task IDs are synthetic:
Node v26.12.1; darwin arm64
git version 2.56.0
commit 59e64eef881632a23f89c4138bcf37f23d92bf93
npm 11.20.0
Effect 4.1.3
control: HTTP 200 returned state=merged
async: result=Success; value=undefined
async: task-status requests=0
async: HTTP calls=POST https://api.bitbucket.org/2.0/repositories/fixture/work/pullrequests/7/merge
RESULT: defect reproduced
The Bitbucket transport at packages/source-control-bitbucket/src/server/BitbucketApi.ts#L995-L1021 reduces every successful response to body and truncated, dropping HTTP status and headers. The merge action at packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts#L745-L763 tries to decode that body as a PR and turns an undecodable asynchronous reply into successful undefined. The server's merge confirmation at apps/server/src/pullRequest/PullRequestService.ts#L3709-L3748 can reread current PR state, but has no task receipt with which to recover the asynchronous failure.
The mutable OpenAPI was fetched afresh and identified as OpenAPI 3.0.0 / API 2.0, SHA-256 35956399bf8d41d2acfd099b3d94ef1a08e0cfd6409a92077b94a1dacbc6abdc. Its 202 response explicitly places the task-status URL in Location.
Additional context#
- https://github.com/pingdotgg/t3code/pull/25761 — maps synchronous merge replies into provider-neutral summaries and deliberately allows a void result when a reply lacks a PR; it does not retain or monitor an asynchronous Bitbucket task.
- https://github.com/pingdotgg/t3code/pull/32842 — adjusts merge interaction timing and notes that Bitbucket does not report merge-queue metadata; it does not cover the loss of the REST task receipt or terminal task errors.
tanstack-source added labelsbug
Sign in to join this conversation.