authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-09-28 21:38:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-28 18:38:24-07:00
log5da55957d48dbd79c1a05b72bdf2479ae0956b1c
treed553050ad91d20574eaa54406e01e53ca06e5c9a
parente518866d28603da273a9357516dd1aef919f1353
signature Signed by PGP key B5690EEEBB952194

feat: save Bitbucket credentials from Source Control settings (#14103)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

13 files changed, 678 insertions(+), 53 deletions(-)

apps/server/scripts/evaluate-thread-titles.ts+3-1
...@@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";...@@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
30import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";30import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
31import * as ServerSettings from "../src/serverSettings.ts";
31import * as VcsProcess from "../src/vcs/VcsProcess.ts";32import * as VcsProcess from "../src/vcs/VcsProcess.ts";
32import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";33import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
33import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";34import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";
...@@ -155,7 +156,8 @@ await Effect.runPromise(...@@ -155,7 +156,8 @@ await Effect.runPromise(
155 GitLabCli.layer,156 GitLabCli.layer,
156 ForgejoCli.layer,157 ForgejoCli.layer,
157 AzureDevOpsCli.layer,158 AzureDevOpsCli.layer,
158 BitbucketApi.layer,159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())),
159 ),161 ),
160 ),162 ),
161 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),163 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
apps/server/src/serverSettings.test.ts+136
...@@ -44,6 +44,20 @@ const makeServerSettingsLayer = () =>...@@ -44,6 +44,20 @@ const makeServerSettingsLayer = () =>
44 ),44 ),
45 );45 );
4646
47/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */
48const makeServerSettingsLayerWithSecrets = () =>
49 ServerSettingsModule.layer.pipe(
50 Layer.provideMerge(ServerSecretStore.layer),
51 Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
52 Layer.provideMerge(
53 Layer.fresh(
54 ServerConfig.layerTest(process.cwd(), {
55 prefix: "t3code-server-settings-test-",
56 }),
57 ),
58 ),
59 );
60
47const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) =>61const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) =>
48 Layer.succeed(62 Layer.succeed(
49 ServerSecretStore.ServerSecretStore,63 ServerSecretStore.ServerSecretStore,
...@@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1278 }).pipe(Effect.provide(makeServerSettingsLayer())),1292 }).pipe(Effect.provide(makeServerSettingsLayer())),
1279 );1293 );
12801294
1295 it.effect(
1296 "keeps Bitbucket tokens in the secret store and tells clients only that one is set",
1297 () =>
1298 Effect.gen(function* () {
1299 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1300 const secrets = yield* ServerSecretStore.ServerSecretStore;
1301 const serverConfig = yield* ServerConfig.ServerConfig;
1302 const fileSystem = yield* FileSystem.FileSystem;
1303
1304 const saved = yield* serverSettings.updateSettings({
1305 bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" },
1306 });
1307 assert.deepEqual(saved.bitbucket, {
1308 email: "me@example.com",
1309 accessToken: "bb-access",
1310 apiToken: "bb-api",
1311 });
1312
1313 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1314 assert.notInclude(raw, "bb-access");
1315 assert.notInclude(raw, "bb-api");
1316 assert.include(raw, "me@example.com");
1317
1318 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket;
1319 assert.equal(forClient.email, "me@example.com");
1320 assert.notInclude(forClient.accessToken, "bb-access");
1321 assert.notInclude(forClient.apiToken, "bb-api");
1322 assert.isAbove(forClient.accessToken.length, 0);
1323 assert.isAbove(forClient.apiToken.length, 0);
1324
1325 // A client echoing the redacted values back, or omitting them, keeps the saved tokens.
1326 yield* serverSettings.updateSettings({ bitbucket: forClient });
1327 yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } });
1328 assert.deepEqual((yield* serverSettings.getSettings).bitbucket, {
1329 email: "other@example.com",
1330 accessToken: "bb-access",
1331 apiToken: "bb-api",
1332 });
1333
1334 const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } });
1335 assert.equal(cleared.bitbucket.accessToken, "");
1336 assert.equal(cleared.bitbucket.apiToken, "bb-api");
1337 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1338 assert.equal(
1339 ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken,
1340 "",
1341 );
1342 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1343 );
1344
1345 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
1346 Effect.gen(function* () {
1347 const serverConfig = yield* ServerConfig.ServerConfig;
1348 const fileSystem = yield* FileSystem.FileSystem;
1349 const secrets = yield* ServerSecretStore.ServerSecretStore;
1350 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1351 // A token was saved, then the user deleted it from settings.json directly.
1352 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token"));
1353 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
1354
1355 yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true });
1356
1357 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1358 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1359 );
1360
1361 it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () =>
1362 Effect.gen(function* () {
1363 const serverConfig = yield* ServerConfig.ServerConfig;
1364 const fileSystem = yield* FileSystem.FileSystem;
1365 const secrets = yield* ServerSecretStore.ServerSecretStore;
1366 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1367 yield* fileSystem.writeFileString(
1368 serverConfig.settingsPath,
1369 '{"bitbucket":{"accessToken":"hand-edited-token"}}',
1370 );
1371
1372 // Loading alone moves it: no settings update is needed.
1373 const loaded = yield* serverSettings.getSettings;
1374
1375 assert.equal(loaded.bitbucket.accessToken, "hand-edited-token");
1376 assert.notInclude(
1377 yield* fileSystem.readFileString(serverConfig.settingsPath),
1378 "hand-edited-token",
1379 );
1380 const stored = yield* secrets.get("bitbucket-access-token");
1381 assert.equal(
1382 Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null,
1383 "hand-edited-token",
1384 );
1385 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1386 );
1387
1388 it.effect(
1389 "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker",
1390 () =>
1391 Effect.gen(function* () {
1392 const serverConfig = yield* ServerConfig.ServerConfig;
1393 const fileSystem = yield* FileSystem.FileSystem;
1394 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1395 yield* fileSystem.writeFileString(
1396 serverConfig.settingsPath,
1397 '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}',
1398 );
1399
1400 // The form resends the redacted token when only the email changes.
1401 const forClient = ServerSettingsModule.redactServerSettingsForClient(
1402 yield* serverSettings.getSettings,
1403 ).bitbucket;
1404 const updated = yield* serverSettings.updateSettings({
1405 bitbucket: { email: "new@example.com", apiToken: forClient.apiToken },
1406 });
1407
1408 assert.equal(updated.bitbucket.apiToken, "hand-edited-token");
1409 assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token");
1410 assert.notInclude(
1411 yield* fileSystem.readFileString(serverConfig.settingsPath),
1412 "hand-edited-token",
1413 );
1414 }).pipe(Effect.provide(makeServerSettingsLayer())),
1415 );
1416
1281 it.effect("materializes provider secrets for terminal environment resolution", () =>1417 it.effect("materializes provider secrets for terminal environment resolution", () =>
1282 Effect.gen(function* () {1418 Effect.gen(function* () {
1283 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1419 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/serverSettings.ts+89-12
...@@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: {...@@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: {
140}140}
141141
142/**142/**
143 * On disk the hub key is replaced by this marker and the real value lives in143 * On disk a hub key or Bitbucket token is replaced by this marker and the
144 * the secret store, mirroring provider environment secrets. A client that144 * real value lives in the secret store, mirroring provider environment
145 * sends the marker back means "keep what you have".145 * secrets. A client that sends the marker back means "keep what you have".
146 */146 */
147const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";147const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
148148
149function usageLimitSourceSecretName(sourceId: string): string {149function usageLimitSourceSecretName(sourceId: string): string {
150 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;150 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
151}151}
152152
153const BITBUCKET_SECRET_NAMES = {
154 accessToken: "bitbucket-access-token",
155 apiToken: "bitbucket-api-token",
156} as const;
157const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;
158
159const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
160
153function redactProviderEnvironmentVariable(161function redactProviderEnvironmentVariable(
154 variable: ProviderInstanceEnvironmentVariable,162 variable: ProviderInstanceEnvironmentVariable,
155): ProviderInstanceEnvironmentVariable {163): ProviderInstanceEnvironmentVariable {
...@@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS...@@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
182 id,190 id,
183 {191 {
184 ...source,192 ...source,
185 managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "",193 managementKey: redactSecret(source.managementKey),
186 },194 },
187 ]),195 ]),
188 );196 );
189 return { ...settings, providerInstances, usageLimitSources };197 const bitbucket = {
198 ...settings.bitbucket,
199 accessToken: redactSecret(settings.bitbucket.accessToken),
200 apiToken: redactSecret(settings.bitbucket.apiToken),
201 };
202 return { ...settings, providerInstances, usageLimitSources, bitbucket };
190}203}
191204
192export class ServerSettingsService extends Context.Service<205export class ServerSettingsService extends Context.Service<
...@@ -555,6 +568,35 @@ const make = Effect.gen(function* () {...@@ -555,6 +568,35 @@ const make = Effect.gen(function* () {
555 ),568 ),
556 );569 );
557570
571 /**
572 * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load,
573 * so plaintext does not stay on disk. If the store is unavailable, the token keeps working
574 * from the file and the move is retried on the next load.
575 */
576 const moveInlineBitbucketTokens = (settings: ServerSettings) =>
577 Effect.gen(function* () {
578 const bitbucket = { ...settings.bitbucket };
579 let moved = false;
580 for (const field of BITBUCKET_SECRET_FIELDS) {
581 const value = bitbucket[field];
582 if (value.length === 0 || value === SECRET_REDACTED) continue;
583 const stored = yield* secretStore
584 .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value))
585 .pipe(
586 Effect.as(true),
587 Effect.catch(() =>
588 Effect.logWarning("failed to move a Bitbucket token into the secret store", {
589 field,
590 }).pipe(Effect.as(false)),
591 ),
592 );
593 if (!stored) continue;
594 bitbucket[field] = SECRET_REDACTED;
595 moved = true;
596 }
597 return moved ? { ...settings, bitbucket } : settings;
598 });
599
558 const loadSettingsFromDisk = Effect.gen(function* () {600 const loadSettingsFromDisk = Effect.gen(function* () {
559 let settings = DEFAULT_SERVER_SETTINGS;601 let settings = DEFAULT_SERVER_SETTINGS;
560 let persisted: typeof PersistedOptionalProviderSettings.Type = {};602 let persisted: typeof PersistedOptionalProviderSettings.Type = {};
...@@ -639,10 +681,12 @@ const make = Effect.gen(function* () {...@@ -639,10 +681,12 @@ const make = Effect.gen(function* () {
639 const folded = settingsFileTrusted681 const folded = settingsFileTrusted
640 ? foldLegacyProjectSettings(loaded, legacyProjectRows)682 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
641 : loaded;683 : loaded;
642 if (folded !== loaded) {684 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
643 yield* writeSettingsAtomically(folded);685 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;
686 if (migrated !== loaded) {
687 yield* writeSettingsAtomically(migrated);
644 }688 }
645 return folded;689 return migrated;
646 });690 });
647691
648 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({692 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
...@@ -693,7 +737,7 @@ const make = Effect.gen(function* () {...@@ -693,7 +737,7 @@ const make = Effect.gen(function* () {
693 }737 }
694 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};738 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
695 for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {739 for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {
696 if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) {740 if (source.managementKey !== SECRET_REDACTED) {
697 usageLimitSources[sourceId] = source;741 usageLimitSources[sourceId] = source;
698 continue;742 continue;
699 }743 }
...@@ -709,10 +753,23 @@ const make = Effect.gen(function* () {...@@ -709,10 +753,23 @@ const make = Effect.gen(function* () {
709 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",753 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
710 };754 };
711 }755 }
756 const bitbucket = { ...settings.bitbucket };
757 for (const field of BITBUCKET_SECRET_FIELDS) {
758 if (bitbucket[field] !== SECRET_REDACTED) continue;
759 const secret = yield* secretStore
760 .get(BITBUCKET_SECRET_NAMES[field])
761 .pipe(
762 Effect.mapError(
763 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
764 ),
765 );
766 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
767 }
712 return {768 return {
713 ...settings,769 ...settings,
714 providerInstances: providerInstances as ServerSettings["providerInstances"],770 providerInstances: providerInstances as ServerSettings["providerInstances"],
715 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],771 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
772 bitbucket,
716 };773 };
717 });774 });
718775
...@@ -829,7 +886,7 @@ const make = Effect.gen(function* () {...@@ -829,7 +886,7 @@ const make = Effect.gen(function* () {
829 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};886 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
830 for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {887 for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {
831 const secretName = usageLimitSourceSecretName(sourceId);888 const secretName = usageLimitSourceSecretName(sourceId);
832 if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) {889 if (source.managementKey === SECRET_REDACTED) {
833 usageLimitSources[sourceId] = source;890 usageLimitSources[sourceId] = source;
834 continue;891 continue;
835 }892 }
...@@ -843,7 +900,7 @@ const make = Effect.gen(function* () {...@@ -843,7 +900,7 @@ const make = Effect.gen(function* () {
843 secretName,900 secretName,
844 value: textEncoder.encode(source.managementKey),901 value: textEncoder.encode(source.managementKey),
845 });902 });
846 usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED };903 usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED };
847 }904 }
848 for (const sourceId of Object.keys(current.usageLimitSources)) {905 for (const sourceId of Object.keys(current.usageLimitSources)) {
849 if (sourceId in next.usageLimitSources) continue;906 if (sourceId in next.usageLimitSources) continue;
...@@ -854,11 +911,31 @@ const make = Effect.gen(function* () {...@@ -854,11 +911,31 @@ const make = Effect.gen(function* () {
854 });911 });
855 }912 }
856913
914 const bitbucket = { ...next.bitbucket };
915 for (const field of BITBUCKET_SECRET_FIELDS) {
916 let value = bitbucket[field];
917 if (value === SECRET_REDACTED) {
918 // The marker keeps what is saved. A plaintext value hand-edited into settings.json
919 // is not in the secret store yet, so move it there instead of dropping it.
920 const inline = current.bitbucket[field];
921 if (inline === SECRET_REDACTED || inline.length === 0) continue;
922 value = inline;
923 }
924 const secretName = BITBUCKET_SECRET_NAMES[field];
925 if (value.length === 0) {
926 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
927 continue;
928 }
929 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
930 bitbucket[field] = SECRET_REDACTED;
931 }
932
857 return {933 return {
858 settings: {934 settings: {
859 ...next,935 ...next,
860 providerInstances: providerInstances as ServerSettings["providerInstances"],936 providerInstances: providerInstances as ServerSettings["providerInstances"],
861 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],937 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
938 bitbucket,
862 },939 },
863 changes,940 changes,
864 };941 };
apps/server/src/sourceControl/BitbucketApi.test.ts+76-1
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
1616
17import { GitCommandError } from "@t3tools/contracts";17import { GitCommandError } from "@t3tools/contracts";
18import * as BitbucketApi from "./BitbucketApi.ts";18import * as BitbucketApi from "./BitbucketApi.ts";
19import * as ServerSettings from "../serverSettings.ts";
19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";20import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
20import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
21import type * as VcsDriver from "../vcs/VcsDriver.ts";22import type * as VcsDriver from "../vcs/VcsDriver.ts";
...@@ -64,6 +65,7 @@ function makeLayer(input: {...@@ -64,6 +65,7 @@ function makeLayer(input: {
64 request: HttpClientRequest.HttpClientRequest,65 request: HttpClientRequest.HttpClientRequest,
65 ) => HttpClientError.HttpClientError;66 ) => HttpClientError.HttpClientError;
66 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;67 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
68 readonly env?: Record<string, string>;
67}) {69}) {
68 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>70 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
69 input.requestFailure71 input.requestFailure
...@@ -150,7 +152,7 @@ function makeLayer(input: {...@@ -150,7 +152,7 @@ function makeLayer(input: {
150 Layer.provide(152 Layer.provide(
151 ConfigProvider.layer(153 ConfigProvider.layer(
152 ConfigProvider.fromEnv({154 ConfigProvider.fromEnv({
153 env: {155 env: input.env ?? {
154 T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",156 T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",
155 T3CODE_BITBUCKET_EMAIL: "user@example.com",157 T3CODE_BITBUCKET_EMAIL: "user@example.com",
156 T3CODE_BITBUCKET_API_TOKEN: "token",158 T3CODE_BITBUCKET_API_TOKEN: "token",
...@@ -158,6 +160,7 @@ function makeLayer(input: {...@@ -158,6 +160,7 @@ function makeLayer(input: {
158 }),160 }),
159 ),161 ),
160 ),162 ),
163 Layer.provideMerge(ServerSettings.layerTest()),
161 Layer.provideMerge(NodeServices.layer),164 Layer.provideMerge(NodeServices.layer),
162 );165 );
163166
...@@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () =>...@@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () =>
509 }).pipe(Effect.provide(layer));512 }).pipe(Effect.provide(layer));
510});513});
511514
515it.effect("prefers credentials saved in settings over the environment, without a restart", () => {
516 const { execute, layer } = makeLayer({
517 response: () => Response.json({ username: "bitbucket-user" }),
518 });
519 const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization;
520 const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`;
521
522 return Effect.gen(function* () {
523 const bitbucket = yield* BitbucketApi.BitbucketApi;
524 const settings = yield* ServerSettings.ServerSettingsService;
525
526 yield* bitbucket.probeAuth;
527 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
528
529 yield* settings.updateSettings({
530 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
531 });
532 yield* bitbucket.probeAuth;
533 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));
534
535 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
536 yield* bitbucket.probeAuth;
537 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");
538
539 yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } });
540 yield* bitbucket.probeAuth;
541 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
542 }).pipe(Effect.provide(layer));
543});
544
545it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => {
546 const { execute, layer } = makeLayer({
547 response: () => Response.json({ username: "bitbucket-user" }),
548 });
549
550 return Effect.gen(function* () {
551 const bitbucket = yield* BitbucketApi.BitbucketApi;
552 const settings = yield* ServerSettings.ServerSettingsService;
553
554 // Fetch would reject this header with an error quoting the token, and that error reaches
555 // clients. The unusable token is ignored, so the environment credential is used instead.
556 yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } });
557 yield* bitbucket.probeAuth;
558 assert.strictEqual(
559 execute.mock.calls.at(-1)?.[0].headers.authorization,
560 `Basic ${btoa("user@example.com:token")}`,
561 );
562 }).pipe(Effect.provide(layer));
563});
564
565it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => {
566 const { layer } = makeLayer({
567 response: () => new Response(null, { status: 401 }),
568 env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" },
569 });
570
571 return Effect.gen(function* () {
572 const bitbucket = yield* BitbucketApi.BitbucketApi;
573 const settings = yield* ServerSettings.ServerSettingsService;
574
575 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");
576
577 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
578 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
579 status: "unknown",
580 account: Option.none(),
581 host: Option.some("bitbucket.org"),
582 detail: Option.some("An access token is configured."),
583 });
584 }).pipe(Effect.provide(layer));
585});
586
512it.effect("preserves the HTTP client failure without deriving the domain message from it", () => {587it.effect("preserves the HTTP client failure without deriving the domain message from it", () => {
513 const transportCause = new Error("socket reset by peer");588 const transportCause = new Error("socket reset by peer");
514 let requestFailure: HttpClientError.HttpClientError | undefined;589 let requestFailure: HttpClientError.HttpClientError | undefined;
apps/server/src/sourceControl/BitbucketApi.ts+79-21
...@@ -7,8 +7,10 @@ import * as Layer from "effect/Layer";...@@ -7,8 +7,10 @@ import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import {9import {
10 DEFAULT_SERVER_SETTINGS,
10 NonNegativeInt,11 NonNegativeInt,
11 TrimmedNonEmptyString,12 TrimmedNonEmptyString,
13 type BitbucketSettings,
12 type SourceControlProviderAuth,14 type SourceControlProviderAuth,
13 type SourceControlRepositoryCloneUrls,15 type SourceControlRepositoryCloneUrls,
14 type SourceControlRepositoryVisibility,16 type SourceControlRepositoryVisibility,
...@@ -28,6 +30,7 @@ import {...@@ -28,6 +30,7 @@ import {
28} from "./bitbucketPullRequests.ts";30} from "./bitbucketPullRequests.ts";
29import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";31import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
30import * as SourceControlProvider from "./SourceControlProvider.ts";32import * as SourceControlProvider from "./SourceControlProvider.ts";
33import * as ServerSettings from "../serverSettings.ts";
31import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";34import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
32import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";35import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
33import { retryAtFromHeader } from "./SourceControlRateLimit.ts";36import { retryAtFromHeader } from "./SourceControlRateLimit.ts";
...@@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string {...@@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string {
537 return repositoryName.split("/")[0]?.trim() || "bitbucket";540 return repositoryName.split("/")[0]?.trim() || "bitbucket";
538}541}
539542
540function authFromConfig(543type BitbucketCredential =
541 config: Config.Success<typeof BitbucketApiEnvConfig>,544 | { readonly kind: "access-token"; readonly accessToken: string }
542): SourceControlProviderAuth {545 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
543 if (Option.isSome(config.accessToken)) {546
547/**
548 * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole
549 * header, and that error travels to clients as a cause, so an unusable token is treated as unset.
550 */
551const HEADER_SAFE = /^[\x21-\x7e]+$/u;
552
553function credentialFrom(input: {
554 readonly accessToken: string;
555 readonly email: string;
556 readonly apiToken: string;
557}): BitbucketCredential | null {
558 if (HEADER_SAFE.test(input.accessToken)) {
559 return { kind: "access-token", accessToken: input.accessToken };
560 }
561 if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) {
562 return { kind: "api-token", email: input.email, apiToken: input.apiToken };
563 }
564 return null;
565}
566
567/**
568 * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which
569 * stay as a fallback. Within each source the access token wins.
570 */
571function resolveCredential(
572 settings: BitbucketSettings,
573 env: Config.Success<typeof BitbucketApiEnvConfig>,
574): BitbucketCredential | null {
575 return (
576 credentialFrom(settings) ??
577 credentialFrom({
578 accessToken: Option.getOrElse(env.accessToken, () => ""),
579 email: Option.getOrElse(env.email, () => ""),
580 apiToken: Option.getOrElse(env.apiToken, () => ""),
581 })
582 );
583}
584
585function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth {
586 if (credential?.kind === "access-token") {
544 return {587 return {
545 status: "unknown",588 status: "unknown",
546 account: Option.none(),589 account: Option.none(),
547 host: Option.some("bitbucket.org"),590 host: Option.some("bitbucket.org"),
548 detail: Option.some("Bitbucket access token is configured."),591 detail: Option.some("An access token is configured."),
549 };592 };
550 }593 }
551594
552 if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {595 if (credential?.kind === "api-token") {
553 return {596 return {
554 status: "unknown",597 status: "unknown",
555 account: config.email,598 account: Option.some(credential.email),
556 host: Option.some("bitbucket.org"),599 host: Option.some("bitbucket.org"),
557 detail: Option.some("Bitbucket API token is configured."),600 detail: Option.some("An API token is configured."),
558 };601 };
559 }602 }
560603
...@@ -563,7 +606,7 @@ function authFromConfig(...@@ -563,7 +606,7 @@ function authFromConfig(
563 account: Option.none(),606 account: Option.none(),
564 host: Option.some("bitbucket.org"),607 host: Option.some("bitbucket.org"),
565 detail: Option.some(608 detail: Option.some(
566 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",609 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
567 ),610 ),
568 };611 };
569}612}
...@@ -612,6 +655,7 @@ function responseError(...@@ -612,6 +655,7 @@ function responseError(
612/** @public Service construction is part of the canonical Effect module API. */655/** @public Service construction is part of the canonical Effect module API. */
613export const make = Effect.gen(function* () {656export const make = Effect.gen(function* () {
614 const config = yield* BitbucketApiEnvConfig;657 const config = yield* BitbucketApiEnvConfig;
658 const serverSettings = yield* ServerSettings.ServerSettingsService;
615 const httpClient = yield* HttpClient.HttpClient;659 const httpClient = yield* HttpClient.HttpClient;
616 const fileSystem = yield* FileSystem.FileSystem;660 const fileSystem = yield* FileSystem.FileSystem;
617 const git = yield* GitVcsDriver.GitVcsDriver;661 const git = yield* GitVcsDriver.GitVcsDriver;
...@@ -619,15 +663,27 @@ export const make = Effect.gen(function* () {...@@ -619,15 +663,27 @@ export const make = Effect.gen(function* () {
619663
620 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;664 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
621665
622 const withAuth = (request: HttpClientRequest.HttpClientRequest) => {666 // Read on every request so credentials saved in settings apply without a restart.
623 if (Option.isSome(config.accessToken)) {667 const currentCredential = serverSettings.getSettings.pipe(
624 return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value));668 Effect.map((settings) => resolveCredential(settings.bitbucket, config)),
625 }669 Effect.catch((error) =>
626 if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {670 // No cause: a settings decode error can quote a hand-edited token.
627 return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value));671 Effect.logWarning("failed to read Bitbucket credentials from settings", {
628 }672 operation: error.operation,
629 return request;673 }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))),
630 };674 ),
675 );
676
677 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
678 currentCredential.pipe(
679 Effect.map((credential) =>
680 credential === null
681 ? request
682 : credential.kind === "access-token"
683 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
684 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
685 ),
686 );
631687
632 const decodeResponse = <S extends Schema.Top>(688 const decodeResponse = <S extends Schema.Top>(
633 operation: BitbucketApiOperation,689 operation: BitbucketApiOperation,
...@@ -654,7 +710,8 @@ export const make = Effect.gen(function* () {...@@ -654,7 +710,8 @@ export const make = Effect.gen(function* () {
654 request: HttpClientRequest.HttpClientRequest,710 request: HttpClientRequest.HttpClientRequest,
655 schema: S,711 schema: S,
656 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>712 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
657 httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe(713 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
714 Effect.flatMap(httpClient.execute),
658 Effect.mapError(715 Effect.mapError(
659 (cause) =>716 (cause) =>
660 new BitbucketRequestError({717 new BitbucketRequestError({
...@@ -847,7 +904,8 @@ export const make = Effect.gen(function* () {...@@ -847,7 +904,8 @@ export const make = Effect.gen(function* () {
847 input.body === undefined904 input.body === undefined
848 ? base905 ? base
849 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));906 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
850 return httpClient.execute(withAuth(withBody)).pipe(907 return withAuth(withBody).pipe(
908 Effect.flatMap(httpClient.execute),
851 Effect.mapError(909 Effect.mapError(
852 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),910 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),
853 ),911 ),
...@@ -913,7 +971,7 @@ export const make = Effect.gen(function* () {...@@ -913,7 +971,7 @@ export const make = Effect.gen(function* () {
913 host: Option.some("bitbucket.org"),971 host: Option.some("bitbucket.org"),
914 detail: Option.none<string>(),972 detail: Option.none<string>(),
915 })),973 })),
916 Effect.orElseSucceed(() => authFromConfig(config)),974 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
917 ),975 ),
918 listPullRequests: (input) =>976 listPullRequests: (input) =>
919 resolveRepository(input).pipe(977 resolveRepository(input).pipe(
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1-2
...@@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () {
192 type: "api",192 type: "api",
193 kind: "bitbucket",193 kind: "bitbucket",
194 label: "Bitbucket",194 label: "Bitbucket",
195 installHint:195 installHint: "Add a Bitbucket token in Settings → Source Control.",
196 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).",
197 probeAuth: bitbucket.probeAuth,196 probeAuth: bitbucket.probeAuth,
198 } satisfies SourceControlApiDiscoverySpec;197 } satisfies SourceControlApiDiscoverySpec;
199});198});
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+1-1
...@@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl...@@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl
423 account: Option.none(),423 account: Option.none(),
424 host: Option.some("bitbucket.org"),424 host: Option.some("bitbucket.org"),
425 detail: Option.some(425 detail: Option.some(
426 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",426 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
427 ),427 ),
428 }),428 }),
429 },429 },
apps/web/src/components/settings/BitbucketCredentialsSettings.tsx created+223
...@@ -0,0 +1,223 @@
1import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button, InlineButton } from "../ui/button";
9import { Input } from "../ui/input";
10import { Label } from "../ui/label";
11import { Toggle, ToggleGroup } from "../ui/toggle-group";
12
13type CredentialMethod = "access-token" | "api-token";
14
15const METHODS: Record<
16 CredentialMethod,
17 {
18 readonly label: string;
19 readonly description: string;
20 readonly link: string;
21 readonly linkLabel: string;
22 }
23> = {
24 "access-token": {
25 label: "Access token",
26 description:
27 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
28 link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/",
29 linkLabel: "Learn more",
30 },
31 "api-token": {
32 label: "API token",
33 description:
34 "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.",
35 link: "https://id.atlassian.com/manage-profile/security/api-tokens",
36 linkLabel: "Create an API token",
37 },
38};
39
40function savedMethod(saved: BitbucketSettings): CredentialMethod | null {
41 if (saved.accessToken.length > 0) return "access-token";
42 if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token";
43 return null;
44}
45
46/** A write-only token field. It never shows the saved token; typing a new one replaces it. */
47function TokenInput({
48 id,
49 isSaved,
50 draft,
51 onDraftChange,
52}: {
53 readonly id: string;
54 readonly isSaved: boolean;
55 readonly draft: string;
56 readonly onDraftChange: (draft: string) => void;
57}) {
58 return (
59 <Input
60 id={id}
61 type="password"
62 autoComplete="off"
63 size="sm"
64 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}
65 value={draft}
66 onChange={(event) => onDraftChange(event.target.value)}
67 />
68 );
69}
70
71/**
72 * Bitbucket credentials for one environment: an access token or an Atlassian
73 * account email + API token, never both. Tokens are write-only: the server
74 * keeps them in its secret store and only reports whether each one is set.
75 */
76export function BitbucketCredentialsSettings({
77 environmentId,
78 onSaved,
79}: {
80 readonly environmentId: EnvironmentId;
81 readonly onSaved: () => void;
82}) {
83 const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket);
84 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
85 label: "save Bitbucket credentials",
86 });
87 const [methodChoice, setMethodChoice] = useState<CredentialMethod | null>(null);
88 const [accessToken, setAccessToken] = useState("");
89 const [emailDraft, setEmailDraft] = useState<string | null>(null);
90 const [apiToken, setApiToken] = useState("");
91 const [saving, setSaving] = useState(false);
92 const current = savedMethod(saved);
93 const method = methodChoice ?? current ?? "access-token";
94 const methodIsSaved = current === method;
95 const email = (emailDraft ?? saved.email).trim();
96 const newAccessToken = accessToken.trim();
97 const newApiToken = apiToken.trim();
98 const info = METHODS[method];
99
100 // Saving one method clears the other, so a hidden credential never wins over the visible one.
101 const patch: BitbucketSettings | null =
102 method === "access-token"
103 ? newAccessToken
104 ? { accessToken: newAccessToken, email: "", apiToken: "" }
105 : null
106 : email && (newApiToken || saved.apiToken)
107 ? // Resending the saved token's redacted value keeps it.
108 { accessToken: "", email, apiToken: newApiToken || saved.apiToken }
109 : null;
110 const canSave =
111 patch !== null &&
112 (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email);
113
114 const save = async (next: BitbucketSettings) => {
115 setSaving(true);
116 try {
117 const result = await updateSettings({
118 environmentId,
119 input: { patch: { bitbucket: next } },
120 });
121 if (result._tag === "Success") {
122 setAccessToken("");
123 setApiToken("");
124 setEmailDraft(null);
125 onSaved();
126 }
127 } finally {
128 setSaving(false);
129 }
130 };
131
132 return (
133 <form
134 className="grid gap-4"
135 onSubmit={(event) => {
136 event.preventDefault();
137 if (canSave && patch) void save(patch);
138 }}
139 >
140 {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */}
141 <fieldset disabled={saving} className="contents">
142 <ToggleGroup
143 aria-label="Bitbucket sign-in method"
144 variant="segmented"
145 value={[method]}
146 onValueChange={(next) => {
147 const value = next[0];
148 if (value === "access-token" || value === "api-token") setMethodChoice(value);
149 }}
150 >
151 <Toggle value="access-token">{METHODS["access-token"].label}</Toggle>
152 <Toggle value="api-token">{METHODS["api-token"].label}</Toggle>
153 </ToggleGroup>
154 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
155 {info.description}{" "}
156 <InlineButton render={<a href={info.link} target="_blank" rel="noreferrer noopener" />}>
157 {info.linkLabel}
158 <ExternalLinkIcon aria-hidden className="size-3" />
159 </InlineButton>
160 </p>
161 {method === "access-token" ? (
162 <div className="grid gap-1.5">
163 <Label htmlFor={`bitbucket-access-token-${environmentId}`}>Access token</Label>
164 <TokenInput
165 id={`bitbucket-access-token-${environmentId}`}
166 isSaved={methodIsSaved}
167 draft={accessToken}
168 onDraftChange={setAccessToken}
169 />
170 </div>
171 ) : (
172 <>
173 <div className="grid gap-1.5">
174 <Label htmlFor={`bitbucket-email-${environmentId}`}>Atlassian account email</Label>
175 <Input
176 id={`bitbucket-email-${environmentId}`}
177 type="email"
178 autoComplete="off"
179 size="sm"
180 placeholder="you@example.com"
181 value={emailDraft ?? saved.email}
182 onChange={(event) => setEmailDraft(event.target.value)}
183 />
184 </div>
185 <div className="grid gap-1.5">
186 <Label htmlFor={`bitbucket-api-token-${environmentId}`}>API token</Label>
187 <TokenInput
188 id={`bitbucket-api-token-${environmentId}`}
189 isSaved={methodIsSaved}
190 draft={apiToken}
191 onDraftChange={setApiToken}
192 />
193 </div>
194 </>
195 )}
196 <div className="flex items-center justify-between gap-3">
197 <p className="text-xs text-muted-foreground">
198 {current === null
199 ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables."
200 : methodIsSaved
201 ? null
202 : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`}
203 </p>
204 <div className="flex shrink-0 gap-2">
205 {current !== null ? (
206 <Button
207 size="xs"
208 variant="outline"
209 disabled={saving}
210 onClick={() => void save({ accessToken: "", email: "", apiToken: "" })}
211 >
212 Remove
213 </Button>
214 ) : null}
215 <Button type="submit" size="xs" disabled={!canSave || saving}>
216 Save
217 </Button>
218 </div>
219 </div>
220 </fieldset>
221 </form>
222 );
223}
apps/web/src/components/settings/SourceControlSettings.tsx+21-3
...@@ -56,6 +56,7 @@ import {...@@ -56,6 +56,7 @@ import {
56 JujutsuIcon,56 JujutsuIcon,
57 type Icon,57 type Icon,
58} from "../Icons";58} from "../Icons";
59import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
59import { RedactedSensitiveText } from "./RedactedSensitiveText";60import { RedactedSensitiveText } from "./RedactedSensitiveText";
60import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";61import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
61import {62import {
...@@ -234,7 +235,9 @@ function itemSummary({...@@ -234,7 +235,9 @@ function itemSummary({
234 );235 );
235 }236 }
236237
237 if (!item.executable) {238 // API integrations have no CLI to sign in with; an unverified saved credential falls
239 // through to the "could not verify" detail instead of repeating the setup hint.
240 if (!item.executable && auth.status === "unauthenticated") {
238 return <span>Available. {item.installHint}</span>;241 return <span>Available. {item.installHint}</span>;
239 }242 }
240243
...@@ -277,7 +280,11 @@ function DiscoveryItemRow({...@@ -277,7 +280,11 @@ function DiscoveryItemRow({
277 const searchTargetId = useSettingsSearchTargetId();280 const searchTargetId = useSettingsSearchTargetId();
278281
279 useEffect(() => {282 useEffect(() => {
280 if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) {283 if (
284 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
285 (item.kind === "bitbucket" &&
286 searchTargetId === searchableSetting("bitbucket-credentials").id)
287 ) {
281 setIsExpanded(true);288 setIsExpanded(true);
282 }289 }
283 }, [item.kind, searchTargetId]);290 }, [item.kind, searchTargetId]);
...@@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() {...@@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() {
586 headerAction={hasVersionControlSystems ? null : scanButton}593 headerAction={hasVersionControlSystems ? null : scanButton}
587 >594 >
588 {result.sourceControlProviders.map((item) => (595 {result.sourceControlProviders.map((item) => (
589 <DiscoveryItemRow key={`provider:${item.kind}`} item={item} />596 <DiscoveryItemRow key={`provider:${item.kind}`} item={item}>
597 {item.kind === "bitbucket" ? (
598 <SettingsSearchTarget id={searchableSetting("bitbucket-credentials").id}>
599 <BitbucketCredentialsSettings
600 // Drafts belong to one environment; switching must not carry them over.
601 key={environmentId}
602 environmentId={environmentId}
603 onSaved={handleScan}
604 />
605 </SettingsSearchTarget>
606 ) : undefined}
607 </DiscoveryItemRow>
590 ))}608 ))}
591 </SettingsSection>609 </SettingsSection>
592 ) : null}610 ) : null}
apps/web/src/components/settings/settingsSearch.ts+8
...@@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [
695 environmentOnly: true,695 environmentOnly: true,
696 scope: "environment-defaults",696 scope: "environment-defaults",
697 },697 },
698 {
699 id: "bitbucket-credentials",
700 title: "Bitbucket credentials",
701 to: "/settings/source-control",
702 searchTerms: ["bitbucket atlassian access token api token email credentials sign in"],
703 environmentOnly: true,
704 scope: "environment-defaults",
705 },
698 {706 {
699 id: "source-control-writing-style",707 id: "source-control-writing-style",
700 title: "Source control writing style",708 title: "Source control writing style",
docs/user/source-control.md+17-10
...@@ -47,23 +47,29 @@ glab auth login...@@ -47,23 +47,29 @@ glab auth login
4747
48### Bitbucket48### Bitbucket
4949
50Set an access token in the server's environment:50Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in:
5151
52```bash52- **Access token**: a token created for one repository, project, or workspace. It can only reach
53export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token"53 what it was created for.
54```54- **API token**: an Atlassian API token for your account, used with your account email. It can
55 reach every repository you can. Give it read/write access to repositories and pull requests, plus
56 user read access (`read:user:bitbucket`).
5557
56Or use an Atlassian account email and API token with read/write access to repositories and pull58Choose **Save**; the change applies right away, and replaces any credential saved with the other
57requests, plus user read access (`read:user:bitbucket`):59method. Credentials are saved on the environment's server, so select a remote environment to
60configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose
61**Remove**.
62
63If no credentials are saved, T3 Code falls back to these variables in the server's environment.
64Restart the server after changing them:
5865
59```bash66```bash
67export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token"
68# or
60export T3CODE_BITBUCKET_EMAIL="you@example.com"69export T3CODE_BITBUCKET_EMAIL="you@example.com"
61export T3CODE_BITBUCKET_API_TOKEN="your-token"70export T3CODE_BITBUCKET_API_TOKEN="your-token"
62```71```
6372
64The access token takes precedence if both are configured. Restart the server after changing these
65variables.
66
67### Azure DevOps73### Azure DevOps
6874
69Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in:75Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in:
...@@ -138,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop....@@ -138,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop.
138## Troubleshooting144## Troubleshooting
139145
140- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,146- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,
141 confirm the running server received the environment variables.147 check the credentials saved in Settings → Source Control, or confirm the running server received
148 the environment variables.
142- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0.149- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0.
143- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS150- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS
144 remotes can require separate setup from the hosting provider's API access.151 remotes can require separate setup from the hosting provider's API access.
packages/contracts/src/pullRequest.ts+2-2
...@@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial<...@@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial<
1277 },1277 },
1278 bitbucket: {1278 bitbucket: {
1279 missing:1279 missing:
1280 "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",1280 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
1281 unauthenticated:1281 unauthenticated:
1282 "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.",1282 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
1283 },1283 },
1284};1284};
12851285
packages/contracts/src/settings.ts+22
...@@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({...@@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({
901});901});
902export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;902export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
903903
904/**
905 * Bitbucket API credentials for this environment, used before the
906 * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's
907 * secret store; settings and clients only see a redaction marker when one is
908 * set. The access token wins when both kinds are configured.
909 */
910export const BitbucketSettings = Schema.Struct({
911 email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
912 accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
913 apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
914});
915export type BitbucketSettings = typeof BitbucketSettings.Type;
916
904export const ObservabilitySettings = Schema.Struct({917export const ObservabilitySettings = Schema.Struct({
905 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),918 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
906 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),919 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
...@@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({...@@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({
1277 Schema.withDecodingDefault(Effect.succeed({})),1290 Schema.withDecodingDefault(Effect.succeed({})),
1278 ),1291 ),
1279 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1292 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1293 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1280 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries1294 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
1281 // this build cannot decode round-trip untouched, as provider instances do.1295 // this build cannot decode round-trip untouched, as provider instances do.
1282 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(1296 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
...@@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({
1538 otlpLogsUrl: Schema.optionalKey(TrimmedString),1552 otlpLogsUrl: Schema.optionalKey(TrimmedString),
1539 }),1553 }),
1540 ),1554 ),
1555 /** An empty token clears it; an omitted one keeps what the server has. */
1556 bitbucket: Schema.optionalKey(
1557 Schema.Struct({
1558 email: Schema.optionalKey(TrimmedString),
1559 accessToken: Schema.optionalKey(TrimmedString),
1560 apiToken: Schema.optionalKey(TrimmedString),
1561 }),
1562 ),
1541 providers: Schema.optionalKey(1563 providers: Schema.optionalKey(
1542 Schema.Struct({1564 Schema.Struct({
1543 codex: Schema.optionalKey(CodexSettingsPatch),1565 codex: Schema.optionalKey(CodexSettingsPatch),