From a7d39c5b788bc02ab058c9c49869b23a3185cd99 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Mon, 18 May 2026 13:18:00 +0200 Subject: [PATCH 01/17] Add broker infra, CLI work and webserver (HTML files not included yet) --- broker/aws/template.yaml | 725 +++++++++ broker/gcp/index.js | 648 ++++++++ broker/gcp/package.json | 1 + broker_assets.go | 33 + broker_commands.go | 1464 ++++++++++++++++++ broker_commands_test.go | 738 +++++++++ broker_data_path.go | 208 +++ broker_lifecycle.go | 247 +++ global_config.go | 315 ++++ global_config_test.go | 134 ++ go.mod | 2 + go.sum | 2 + identity.go | 167 ++ local_config.go | 85 + local_extra.go | 175 ++- local_native.go | 111 +- main.go | 663 ++++++-- main_test.go | 857 +++++++++-- native_git.go | 153 +- setup.go | 3065 ++++++++++++++++++++++++++++++++++++ setup_test.go | 878 +++++++++++ ssh.go | 1123 +++++--------- web.go | 3162 +++++++++++++++++++++++++++++++++----- 23 files changed, 13498 insertions(+), 1458 deletions(-) create mode 100644 broker/aws/template.yaml create mode 100644 broker/gcp/index.js create mode 100644 broker/gcp/package.json create mode 100644 broker_assets.go create mode 100644 broker_commands.go create mode 100644 broker_commands_test.go create mode 100644 broker_data_path.go create mode 100644 broker_lifecycle.go create mode 100644 global_config.go create mode 100644 global_config_test.go create mode 100644 identity.go create mode 100644 setup.go create mode 100644 setup_test.go diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml new file mode 100644 index 0000000..bbb96a1 --- /dev/null +++ b/broker/aws/template.yaml @@ -0,0 +1,725 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Minimal bgit SSH broker control-plane endpoint. +Resources: + BrokerTransferRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub bgit-broker-transfer-${AWS::Region} + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Sub arn:aws:iam::${AWS::AccountId}:root + Action: + - sts:AssumeRole + - sts:TagSession + Condition: + ArnEquals: + aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/bgit-broker-${AWS::Region} + Policies: + - PolicyName: bgit-transfer-session-boundary + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:AbortMultipartUpload + Resource: arn:aws:s3:::*/* + - Effect: Allow + Action: + - s3:ListBucket + - s3:CreateBucket + - s3:HeadBucket + Resource: arn:aws:s3:::* + BrokerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub bgit-broker-${AWS::Region} + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: bgit-broker-table + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:Query + Resource: + - !GetAtt BrokerTable.Arn + - !GetAtt BrokerPullRequestTable.Arn + - Effect: Allow + Action: + - sts:AssumeRole + - sts:TagSession + Resource: !GetAtt BrokerTransferRole.Arn + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:ListBucket + - s3:CreateBucket + - s3:HeadBucket + Resource: + - arn:aws:s3:::* + - arn:aws:s3:::*/* + BrokerTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-repos + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + BrokerPullRequestTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-prs + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: repo_id + AttributeType: S + - AttributeName: pr_id + AttributeType: N + KeySchema: + - AttributeName: repo_id + KeyType: HASH + - AttributeName: pr_id + KeyType: RANGE + BrokerFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: bgit-broker + Runtime: nodejs22.x + Handler: index.handler + Role: !GetAtt BrokerRole.Arn + Environment: + Variables: + TABLE_NAME: !Ref BrokerTable + PR_TABLE_NAME: !Ref BrokerPullRequestTable + TRANSFER_ROLE_ARN: !GetAtt BrokerTransferRole.Arn + BROKER_VERSION: {{BROKER_VERSION}} + Code: + ZipFile: | + const crypto = require("crypto"); + const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand} = require("@aws-sdk/client-dynamodb"); + const {S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadBucketCommand, CreateBucketCommand} = require("@aws-sdk/client-s3"); + const {STSClient, AssumeRoleCommand} = require("@aws-sdk/client-sts"); + const db = new DynamoDBClient({}); + const s3 = new S3Client({}); + const sts = new STSClient({}); + const table = process.env.TABLE_NAME; + const prTable = process.env.PR_TABLE_NAME; + const transferRoleArn = process.env.TRANSFER_ROLE_ARN; + const brokerVersion = process.env.BROKER_VERSION || "{{BROKER_VERSION}}"; + const zero = "0000000000000000000000000000000000000000"; + function response(statusCode, body) { + return {statusCode, headers: {"content-type": "application/json"}, body: JSON.stringify(body)}; + } + function repoID(repo) { + if (repo && repo.logical) return ["logical", repo.logical].join(":"); + return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); + } + function docID(repo) { + return Buffer.from(repoID(repo)).toString("base64url"); + } + function cleanName(value) { + return String(value || "repo").toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "repo"; + } + function randomSuffix() { + return crypto.randomBytes(5).toString("hex"); + } + async function loadRepo(repo) { + if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error("repo is required"); + const id = docID(repo); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + let data = {repo, keys: [], audit: []}; + if (out.Item) data = JSON.parse(out.Item.data.S || "{}"); + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.audit = data.audit || []; + const owners = await loadOwners(); + for (const owner of owners.data.keys || []) { + if (owner.role === "owner" && !data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) data.keys.push(owner); + } + return {id, data}; + } + async function saveRepo(entry) { + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); + } + function prKey(repoID, id) { + return {repo_id: {S: repoID}, pr_id: {N: String(Number(id))}}; + } + async function savePR(entry, pr) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...prKey(entry.id, pr.id), data: {S: JSON.stringify(pr)}}})); + } + async function loadPR(entry, id) { + const out = await db.send(new GetItemCommand({TableName: prTable, Key: prKey(entry.id, id)})); + if (!out.Item) return null; + return JSON.parse(out.Item.data.S || "{}"); + } + async function listPRs(entry) { + const prs = []; + let startKey = undefined; + do { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo", + ExpressionAttributeValues: {":repo": {S: entry.id}}, + ScanIndexForward: false, + ExclusiveStartKey: startKey + })); + for (const item of out.Items || []) prs.push(JSON.parse(item.data.S || "{}")); + startKey = out.LastEvaluatedKey; + } while (startKey); + return prs; + } + async function syncPRRecords(entry, known) { + const knownMap = known && typeof known === "object" ? known : {}; + const prs = await listPRs(entry); + const present = new Set(prs.map((pr) => String(pr.id))); + const deleted = Object.keys(knownMap).filter((id) => !present.has(String(id))).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return { + prs: prs.filter((pr) => String(pr.version || "") !== String(knownMap[String(pr.id)] || "")), + deleted, + }; + } + async function loadOwners() { + const id = "_owners"; + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + if (!out.Item) return {id, data: {keys: [], audit: []}}; + const data = JSON.parse(out.Item.data.S || "{}"); + data.keys = data.keys || []; + data.audit = data.audit || []; + return {id, data}; + } + function audit(entry, event) { + entry.data.audit = (entry.data.audit || []).concat([{...event, at: new Date().toISOString()}]).slice(-500); + } + function readSSHString(buf, offset) { + const len = buf.readUInt32BE(offset); + const start = offset + 4; + return {value: buf.subarray(start, start + len), offset: start + len}; + } + function header(event, name) { + const headers = event.headers || {}; + return headers[name] || headers[name.toLowerCase()] || ""; + } + function expectedMessage(rawBody) { + const digest = crypto.createHash("sha256").update(Buffer.from(rawBody || "{}")).digest("base64"); + return Buffer.from("bgit-broker-v1\n" + digest).toString("base64"); + } + function normalizeKey(key) { + return String(key || "").trim().split(/\s+/).slice(0, 2).join(" "); + } + function publicKeyObject(publicKey) { + const parts = normalizeKey(publicKey).split(/\s+/); + if (parts[0] !== "ssh-ed25519") return crypto.createPublicKey(publicKey); + const blob = Buffer.from(parts[1], "base64"); + let parsed = readSSHString(blob, 0); + if (parsed.value.toString() !== "ssh-ed25519") throw new Error("unsupported SSH key algorithm"); + parsed = readSSHString(blob, parsed.offset); + const derPrefix = Buffer.from("302a300506032b6570032100", "hex"); + return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: "der", type: "spki"}); + } + function signedKey(event, entry) { + const keys = (entry.data.keys || []).filter((k) => !k.suspended); + const publicKey = normalizeKey(header(event, "x-bgit-key")); + const message = String(header(event, "x-bgit-signature-message")); + const signature = String(header(event, "x-bgit-signature")); + if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; + const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); + if (!key) return null; + const parsed = readSSHString(Buffer.from(signature, "base64"), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; + const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; + if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; + return key; + } + function verifySignature(event, entry) { + const adminKeys = (entry.data.keys || []).filter((k) => (k.role === "admin" || k.role === "owner") && !k.suspended); + if (adminKeys.length === 0) return true; + const key = signedKey(event, entry); + return !!key && (key.role === "admin" || key.role === "owner"); + } + function roleAllows(role, operation) { + if (role === "owner" || role === "admin") return true; + if (operation === "read") return ["read", "triage", "developer", "maintainer"].includes(role); + if (operation === "write") return ["developer", "maintainer"].includes(role); + if (operation === "merge") return ["maintainer"].includes(role); + return false; + } + function validRole(role) { + return ["owner", "admin", "maintainer", "developer", "triage", "read"].includes(role); + } + function normalizeRole(role) { + return role === "write" ? "developer" : role; + } + function requireAdmin(event, entry) { + if (!verifySignature(event, entry)) throw Object.assign(new Error("admin SSH signature required"), {statusCode: 403}); + } + function requireOperation(event, entry, operation) { + const key = signedKey(event, entry); + if (!key || !roleAllows(key.role, operation)) throw Object.assign(new Error(operation + " SSH signature required"), {statusCode: 403}); + return key; + } + function cleanObjectPath(value) { + const path = String(value || "").replace(/^\/+/, ""); + if (path.includes("\0") || path.includes("..")) throw new Error("invalid object path"); + return path; + } + function objectName(repo, objectPath) { + const prefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); + const path = cleanObjectPath(objectPath); + return prefix ? prefix + "/" + path : path; + } + async function ensurePhysicalRepo(entry) { + const repo = entry.data.repo || {}; + if (repo.bucket && repo.prefix) return repo; + const logical = cleanName(repo.logical || repo.prefix || "repo.git"); + const suffix = entry.data.bucket_suffix || randomSuffix(); + const bucket = `bgit-${logical.replace(/\.git$/, "")}-${suffix}`.slice(0, 63).replace(/\.+$/g, ""); + try { + await s3.send(new HeadBucketCommand({Bucket: bucket})); + } catch (err) { + await s3.send(new CreateBucketCommand({Bucket: bucket})); + } + entry.data.bucket_suffix = suffix; + entry.data.repo = {...repo, provider: "s3", bucket, prefix: "repo.git"}; + await saveRepo(entry); + return entry.data.repo; + } + async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); + } + async function readObject(repo, objectPath) { + const out = await s3.send(new GetObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); + const data = await streamToBuffer(out.Body); + return data.toString("base64"); + } + async function writeTextObject(repo, objectPath, value) { + await s3.send(new PutObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath), Body: value})); + } + async function deleteObject(repo, objectPath) { + await s3.send(new DeleteObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); + } + async function listObjects(repo, prefix) { + const repoPrefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); + const queryPrefix = objectName(repo, prefix); + const paths = []; + let token = undefined; + do { + const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, Prefix: queryPrefix, ContinuationToken: token})); + for (const item of out.Contents || []) { + const strip = repoPrefix ? repoPrefix + "/" : ""; + paths.push(item.Key.startsWith(strip) ? item.Key.slice(strip.length) : item.Key); + } + token = out.NextContinuationToken; + } while (token); + return paths; + } + function policyFor(repo, objectPath, operation) { + const key = objectName(repo, objectPath); + const actions = operation === "read" ? ["s3:GetObject"] : operation === "delete" ? ["s3:DeleteObject"] : ["s3:PutObject", "s3:AbortMultipartUpload"]; + return JSON.stringify({Version: "2012-10-17", Statement: [ + {Effect: "Allow", Action: actions, Resource: `arn:aws:s3:::${repo.bucket}/${key}`}, + {Effect: "Allow", Action: ["s3:ListBucket"], Resource: `arn:aws:s3:::${repo.bucket}`, Condition: {StringLike: {"s3:prefix": [`${repo.prefix}/*`]}}} + ]}); + } + async function objectCapability(repo, objectPath, operation, key) { + const out = await sts.send(new AssumeRoleCommand({ + RoleArn: transferRoleArn, + RoleSessionName: `bgit-${operation}-${Date.now()}`, + DurationSeconds: 900, + Policy: policyFor(repo, objectPath, operation), + Tags: [ + {Key: "bgit-operation", Value: operation}, + {Key: "bgit-user", Value: String(key.user || "unknown").slice(0, 128)}, + {Key: "bgit-repo", Value: String(repo.prefix || "repo").slice(0, 128)} + ] + })); + return { + provider: "s3", mode: "sts", bucket: repo.bucket, prefix: repo.prefix, + object: objectName(repo, objectPath), region: process.env.AWS_REGION, + expires_in: 900, + credentials: { + access_key_id: out.Credentials.AccessKeyId, + secret_access_key: out.Credentials.SecretAccessKey, + session_token: out.Credentials.SessionToken + } + }; + } + function protectionFor(data, ref) { + return (data.protections || []).find((p) => p.ref === ref); + } + function assertRefAllowed(data, ref, key, opts) { + const protection = protectionFor(data, ref); + if (!protection || !protection.require_pr) return; + if (opts && opts.fromPR) return; + if (protection.allow_overrides && key && (key.role === "owner" || key.role === "admin")) return; + throw Object.assign(new Error(`protected branch ${ref} requires a pull request`), {statusCode: 403}); + } + function nextPRID(data) { + data.next_pr_id = Number(data.next_pr_id || 1); + return data.next_pr_id++; + } + function findPR(data, id) { + return (data.prs || []).find((pr) => Number(pr.id) === Number(id)); + } + function nextPRNoteID(pr) { + pr.next_note_id = Number(pr.next_note_id || 1); + return pr.next_note_id++; + } + function bumpPRVersion(data, pr) { + const now = new Date().toISOString(); + data.next_pr_version = Number(data.next_pr_version || 1); + pr.version = `${data.next_pr_version++}-${crypto.randomBytes(4).toString("hex")}`; + pr.updated_at = now; + return pr; + } + function ensurePRVersions(data) { + let changed = false; + for (const pr of data.prs || []) { + if (!pr.version) { + bumpPRVersion(data, pr); + changed = true; + } + } + return changed; + } + function syncPRs(data, known) { + const knownMap = known && typeof known === "object" ? known : {}; + return (data.prs || []).filter((pr) => String(pr.version || "") !== String(knownMap[String(pr.id)] || "")); + } + function countApprovals(pr) { + const latest = new Map(); + for (const review of pr.reviews || []) { + if (review.user) latest.set(review.user, review.state); + } + return Array.from(latest.values()).filter((state) => state === "approved").length; + } + async function updateRefCAS(repo, ref, oldHash, newHash, key, opts = {}) { + const id = docID(repo); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + const oldData = out.Item && out.Item.data ? out.Item.data.S : ""; + const data = oldData ? JSON.parse(oldData || "{}") : {repo, keys: [], refs: {}, audit: []}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.refs = data.refs || {}; + data.protections = data.protections || []; + assertRefAllowed(data, ref, key, opts); + const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; + if (current !== oldHash) throw Object.assign(new Error("stale ref"), {statusCode: 409}); + if (newHash === zero) delete data.refs[ref]; + else data.refs[ref] = newHash; + audit({data}, {type: "ref_update", ref, old: oldHash, new: newHash}); + const item = {id: {S: id}, data: {S: JSON.stringify(data)}}; + const input = {TableName: table, Item: item}; + if (oldData) { + input.ConditionExpression = "#data = :old"; + input.ExpressionAttributeNames = {"#data": "data"}; + input.ExpressionAttributeValues = {":old": {S: oldData}}; + } else { + input.ConditionExpression = "attribute_not_exists(id)"; + } + try { + await db.send(new PutItemCommand(input)); + } catch (err) { + if (err.name === "ConditionalCheckFailedException") throw Object.assign(new Error("stale ref"), {statusCode: 409}); + throw err; + } + } + exports.handler = async (event) => { + const path = event.rawPath || "/"; + const method = event.requestContext && event.requestContext.http ? event.requestContext.http.method : "GET"; + const body = event.body ? JSON.parse(event.body) : {}; + try { + if (path === "/" || path === "/health") return response(200, {ok: true, service: "bgit-broker", version: brokerVersion}); + if (path === "/owners/upsert" && method === "POST") { + const entry = await loadOwners(); + if (!verifySignature(event, entry)) throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const user = body.user || "owner"; + const role = normalizeRole(body.role || "owner"); + if (role !== "owner") throw new Error("owner bootstrap only accepts owner role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/repos/upsert" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + const user = body.admin_user || "admin"; + const role = normalizeRole(body.role || "admin"); + if (!validRole(role)) throw new Error("invalid role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + audit(entry, {type: "repo_upsert", user}); + await saveRepo(entry); + return response(200, {ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix}); + } + if (path === "/keys/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + return response(200, {keys: entry.data.keys}); + } + if (path === "/keys/add" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const user = body.user || "admin"; + const role = normalizeRole(body.role || "read"); + if (!validRole(role) || role === "owner") throw new Error("invalid role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + await saveRepo(entry); + return response(200, {ok: true}); + } + if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const key = String(body.key || "").trim(); + const normalized = normalizeKey(key); + const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); + if (entry.data.keys.some((k) => match(k) && k.role === "owner")) throw Object.assign(new Error("owners cannot be removed or suspended"), {statusCode: 403}); + if (path === "/keys/remove") entry.data.keys = entry.data.keys.filter((k) => !match(k)); + else for (const item of entry.data.keys) if (match(item)) item.suspended = true; + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/owners/transfer" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const target = String(body.key || "").trim(); + const normalized = normalizeKey(target); + const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(target); + let changed = false; + for (const item of entry.data.keys) { + if (match(item)) { + item.role = "owner"; + item.user = body.user || item.user; + changed = true; + } else if (item.role === "owner" && normalizeKey(item.public_key) === normalizeKey(key.public_key)) { + item.role = "admin"; + } + } + if (!changed) throw new Error("target key not found"); + audit(entry, {type: "owner_transfer", user: body.user || ""}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/protection/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + return response(200, {protections: entry.data.protections || []}); + } + if (path === "/protection/upsert" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); + audit(entry, {type: "protection_upsert", ref: body.ref}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/protection/remove" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + audit(entry, {type: "protection_remove", ref: body.ref}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/prs/create" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + entry.data.refs = entry.data.refs || {}; + const pr = {...(body.pr || {})}; + pr.id = nextPRID(entry.data); + pr.status = "open"; + pr.author = key.user; + pr.approvals = pr.approvals || 0; + pr.checks = pr.checks || []; + pr.head = entry.data.refs[pr.source] || ""; + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_create", id: pr.id, source: pr.source, target: pr.target, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {pr}); + } + if (path === "/prs/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, {prs: await listPRs(entry)}); + } + if (path === "/prs/sync" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, await syncPRRecords(entry, body.known || {})); + } + if (path === "/prs/view" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + return response(200, {pr}); + } + if (path === "/prs/close" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + pr.status = "closed"; + pr.closed_by = key.user; + pr.closed_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_close", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/comment" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw Object.assign(new Error("comment is required"), {statusCode: 400}); + pr.comments = pr.comments || []; + pr.comments.push({id: nextPRNoteID(pr), user: key.user, body: comment, at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_comment", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/review" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const state = String(body.review || "").trim(); + if (!["approved", "changes_requested"].includes(state)) throw Object.assign(new Error("unsupported review state"), {statusCode: 400}); + pr.reviews = pr.reviews || []; + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || "").trim(), state, at: new Date().toISOString()}); + pr.approvals = countApprovals(pr); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_review", id: pr.id, user: key.user, state}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/merge" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "merge"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + if (pr.status !== "open") throw new Error("pull request is not open"); + const newHash = (entry.data.refs || {})[pr.source] || pr.head; + if (!newHash) throw new Error("pull request source ref has no head"); + const oldHash = (entry.data.refs || {})[pr.target] || zero; + await updateRefCAS(body.repo, pr.target, oldHash, newHash, key, {fromPR: true}); + const repo = await ensurePhysicalRepo(entry); + await writeTextObject(repo, pr.target, newHash + "\n"); + entry.data.refs = entry.data.refs || {}; + entry.data.refs[pr.target] = newHash; + pr.status = "merged"; + pr.merged_by = key.user; + pr.merged_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + if (body.delete_branch && pr.source && pr.source !== pr.target) { + delete entry.data.refs[pr.source]; + await deleteObject(repo, pr.source); + audit(entry, {type: "branch_delete", ref: pr.source, from_pr: pr.id, user: key.user}); + } + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/auth/check" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + const operation = body.operation || ""; + const allowed = !!key && roleAllows(key.role, operation); + return response(200, {allowed, user: key && key.user, role: key && key.role}); + } + if (path === "/objects/capability" && method === "POST") { + const entry = await loadRepo(body.repo); + const operation = body.operation || "read"; + const key = requireOperation(event, entry, operation === "read" ? "read" : "write"); + const repo = await ensurePhysicalRepo(entry); + const capability = await objectCapability(repo, body.path, operation, key); + audit(entry, {type: "capability_issued", operation, path: body.path, user: key.user, role: key.role}); + await saveRepo(entry); + return response(200, capability); + } + if (path === "/objects/read" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const repo = await ensurePhysicalRepo(entry); + return response(200, {data: await readObject(repo, body.path)}); + } + if (path === "/objects/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const repo = await ensurePhysicalRepo(entry); + return response(200, {paths: await listObjects(repo, body.prefix)}); + } + if (path === "/refs/update" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); + return response(200, {ok: true}); + } + return response(404, {error: "unknown broker endpoint"}); + } catch (err) { + return response(err.statusCode || 500, {error: err.message || String(err)}); + } + }; + BrokerFunctionUrl: + Type: AWS::Lambda::Url + Properties: + TargetFunctionArn: !Ref BrokerFunction + AuthType: NONE + BrokerFunctionUrlPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BrokerFunction + Action: lambda:InvokeFunctionUrl + Principal: '*' + FunctionUrlAuthType: NONE + BrokerFunctionInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BrokerFunction + Action: lambda:InvokeFunction + Principal: '*' + InvokedViaFunctionUrl: true +Outputs: + BrokerUrl: + Value: !GetAtt BrokerFunctionUrl.FunctionUrl diff --git a/broker/gcp/index.js b/broker/gcp/index.js new file mode 100644 index 0000000..06f20d8 --- /dev/null +++ b/broker/gcp/index.js @@ -0,0 +1,648 @@ +'use strict'; + +const crypto = require('crypto'); +const {Firestore} = require('@google-cloud/firestore'); +const {Storage} = require('@google-cloud/storage'); +const {GoogleAuth} = require('google-auth-library'); + +const db = new Firestore({databaseId: process.env.FIRESTORE_DATABASE || 'bgit'}); +const repos = db.collection('bgit_broker_repos'); +const storage = new Storage(); +const auth = new GoogleAuth({scopes: ['https://www.googleapis.com/auth/cloud-platform']}); +const brokerVersion = process.env.BROKER_VERSION || '{{BROKER_VERSION}}'; +const zero = '0000000000000000000000000000000000000000'; + +function repoID(repo) { + if (repo && repo.logical) return ['logical', repo.logical].join(':'); + return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); +} + +function docID(repo) { + return Buffer.from(repoID(repo)).toString('base64url'); +} + +function cleanName(value) { + return String(value || 'repo').toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'repo'; +} + +function randomSuffix() { + return crypto.randomBytes(5).toString('hex'); +} + +async function loadRepo(repo) { + const ref = repos.doc(docID(repo)); + const snap = await ref.get(); + if (!snap.exists) return {ref, data: {repo, keys: [], audit: []}}; + const data = snap.data() || {}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {ref, data}; +} + +async function saveRepo(entry) { + await entry.ref.set(entry.data, {merge: true}); +} + +function prDoc(entry, id) { + return entry.ref.collection('prs').doc(String(id).padStart(10, '0')); +} + +async function savePR(entry, pr) { + await prDoc(entry, pr.id).set(pr, {merge: false}); +} + +async function loadPR(entry, id) { + const snap = await prDoc(entry, id).get(); + if (!snap.exists) return null; + return snap.data() || null; +} + +async function listPRs(entry) { + const snap = await entry.ref.collection('prs').orderBy('id', 'desc').get(); + return snap.docs.map((doc) => doc.data() || {}); +} + +async function syncPRRecords(entry, known) { + const knownMap = known && typeof known === 'object' ? known : {}; + const prs = await listPRs(entry); + const present = new Set(prs.map((pr) => String(pr.id))); + const deleted = Object.keys(knownMap).filter((id) => !present.has(String(id))).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return { + prs: prs.filter((pr) => String(pr.version || '') !== String(knownMap[String(pr.id)] || '')), + deleted, + }; +} + +async function loadOwners() { + const ref = repos.doc('_owners'); + const snap = await ref.get(); + if (!snap.exists) return {ref, data: {keys: [], audit: []}}; + const data = snap.data() || {}; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {ref, data}; +} + +function audit(entry, event) { + entry.data.audit = entry.data.audit || []; + entry.data.audit.push({...event, at: new Date().toISOString()}); + if (entry.data.audit.length > 500) entry.data.audit = entry.data.audit.slice(-500); +} + +function readSSHString(buf, offset) { + const len = buf.readUInt32BE(offset); + const start = offset + 4; + return {value: buf.subarray(start, start + len), offset: start + len}; +} + +function rawBody(req) { + if (req.rawBody) return Buffer.from(req.rawBody); + return Buffer.from(JSON.stringify(req.body || {})); +} + +function expectedMessage(req) { + const digest = crypto.createHash('sha256').update(rawBody(req)).digest('base64'); + return Buffer.from('bgit-broker-v1\n' + digest).toString('base64'); +} + +function normalizeKey(key) { + return String(key || '').trim().split(/\s+/).slice(0, 2).join(' '); +} + +function publicKeyObject(publicKey) { + const parts = normalizeKey(publicKey).split(/\s+/); + if (parts[0] !== 'ssh-ed25519') return crypto.createPublicKey(publicKey); + const blob = Buffer.from(parts[1], 'base64'); + let parsed = readSSHString(blob, 0); + const alg = parsed.value.toString(); + if (alg !== 'ssh-ed25519') throw new Error('unsupported SSH key algorithm'); + parsed = readSSHString(blob, parsed.offset); + const derPrefix = Buffer.from('302a300506032b6570032100', 'hex'); + return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: 'der', type: 'spki'}); +} + +function signedKey(req, entry) { + const keys = (entry.data.keys || []).filter((k) => !k.suspended); + const publicKey = normalizeKey(req.get('x-bgit-key')); + const message = String(req.get('x-bgit-signature-message') || ''); + const signature = String(req.get('x-bgit-signature') || ''); + if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; + const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); + if (!key) return null; + const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; + const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; + if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; + return key; +} + +function verifySignature(req, entry) { + const adminKeys = (entry.data.keys || []).filter((k) => (k.role === 'admin' || k.role === 'owner') && !k.suspended); + if (adminKeys.length === 0) return true; + const key = signedKey(req, entry); + return !!key && (key.role === 'admin' || key.role === 'owner'); +} + +function roleAllows(role, operation) { + if (role === 'owner') return true; + if (role === 'admin') return true; + if (operation === 'read') return ['read', 'triage', 'developer', 'maintainer'].includes(role); + if (operation === 'write') return ['developer', 'maintainer'].includes(role); + if (operation === 'merge') return ['maintainer'].includes(role); + return false; +} + +function validRole(role) { + return ['owner', 'admin', 'maintainer', 'developer', 'triage', 'read'].includes(role); +} + +function normalizeRole(role) { + return role === 'write' ? 'developer' : role; +} + +function requireAdmin(req, entry) { + if (!verifySignature(req, entry)) { + const err = new Error('admin SSH signature required'); + err.status = 403; + throw err; + } +} + +function requireRead(req, entry) { + const key = signedKey(req, entry); + if (!key || !roleAllows(key.role, 'read')) { + const err = new Error('read SSH signature required'); + err.status = 403; + throw err; + } + return key; +} + +function requireWrite(req, entry) { + const key = signedKey(req, entry); + if (!key || !roleAllows(key.role, 'write')) { + const err = new Error('write SSH signature required'); + err.status = 403; + throw err; + } + return key; +} + +function cleanObjectPath(value) { + const path = String(value || '').replace(/^\/+/, ''); + if (path.includes('\0') || path.includes('..')) throw new Error('invalid object path'); + return path; +} + +function objectName(repo, objectPath) { + const prefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); + const path = cleanObjectPath(objectPath); + return prefix ? prefix + '/' + path : path; +} + +async function ensurePhysicalRepo(entry) { + const repo = entry.data.repo || {}; + if (repo.bucket && repo.prefix) return repo; + const logical = cleanName(repo.logical || repo.prefix || 'repo.git'); + const suffix = entry.data.bucket_suffix || randomSuffix(); + const bucket = `bgit-${logical.replace(/\.git$/, '')}-${suffix}`.slice(0, 63).replace(/\.+$/g, ''); + const prefix = 'repo.git'; + try { + await storage.bucket(bucket).get({autoCreate: true}); + } catch (err) { + await storage.createBucket(bucket); + } + entry.data.bucket_suffix = suffix; + entry.data.repo = {...repo, provider: 'gcs', bucket, prefix}; + await saveRepo(entry); + return entry.data.repo; +} + +async function readObject(repo, objectPath) { + const [data] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).download(); + return data.toString('base64'); +} + +async function writeTextObject(repo, objectPath, value) { + await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).save(value); +} + +async function deleteObject(repo, objectPath) { + await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).delete({ignoreNotFound: true}); +} + +async function listObjects(repo, prefix) { + const repoPrefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); + const queryPrefix = objectName(repo, prefix); + const [files] = await storage.bucket(repo.bucket).getFiles({prefix: queryPrefix}); + const strip = repoPrefix ? repoPrefix + '/' : ''; + return files.map((file) => file.name.startsWith(strip) ? file.name.slice(strip.length) : file.name); +} + +async function serviceAccountEmail() { + if (process.env.BGIT_SIGNING_SERVICE_ACCOUNT) return process.env.BGIT_SIGNING_SERVICE_ACCOUNT; + const client = await auth.getClient(); + const projectId = await auth.getProjectId(); + return `${projectId}@appspot.gserviceaccount.com`; +} + +async function signedURL(repo, objectPath, operation) { + const action = operation === 'write' ? 'write' : operation === 'delete' ? 'delete' : 'read'; + const method = action === 'write' ? 'PUT' : action === 'delete' ? 'DELETE' : 'GET'; + const file = storage.bucket(repo.bucket).file(objectName(repo, objectPath)); + const [url] = await file.getSignedUrl({ + version: 'v4', + action, + expires: Date.now() + 10 * 60 * 1000, + method, + virtualHostedStyle: false, + extensionHeaders: action === 'write' ? {'content-type': 'application/octet-stream'} : undefined, + cname: undefined, + accessibleAt: undefined, + signingEndpoint: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' + await serviceAccountEmail() + ':signBlob', + }); + return {provider: 'gcs', mode: 'signed_url', method, url, headers: action === 'write' ? {'content-type': 'application/octet-stream'} : {}, expires_in: 600}; +} + +async function resumableUpload(repo, objectPath) { + const [uri] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).createResumableUpload({metadata: {contentType: 'application/octet-stream'}}); + return {provider: 'gcs', mode: 'resumable_upload', method: 'PUT', url: uri, headers: {}, expires_in: 600}; +} + +function protectionFor(data, ref) { + return (data.protections || []).find((p) => p.ref === ref); +} + +function assertRefAllowed(data, ref, key, opts) { + const protection = protectionFor(data, ref); + if (!protection || !protection.require_pr) return; + if (opts && opts.fromPR) return; + if (protection.allow_overrides && key && (key.role === 'owner' || key.role === 'admin')) return; + const err = new Error(`protected branch ${ref} requires a pull request`); + err.status = 403; + throw err; +} + +async function updateRefCAS(repo, ref, oldHash, newHash, key, opts = {}) { + const id = docID(repo); + const refDoc = repos.doc(id); + await db.runTransaction(async (tx) => { + const snap = await tx.get(refDoc); + const data = snap.exists ? (snap.data() || {}) : {repo, keys: [], refs: {}, audit: []}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.refs = data.refs || {}; + data.protections = data.protections || []; + assertRefAllowed(data, ref, key, opts); + const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; + if (current !== oldHash) { + const err = new Error('stale ref'); + err.status = 409; + throw err; + } + if (newHash === zero) delete data.refs[ref]; + else data.refs[ref] = newHash; + data.audit = (data.audit || []).concat([{type: 'ref_update', ref, old: oldHash, new: newHash, at: new Date().toISOString()}]).slice(-500); + tx.set(refDoc, data, {merge: true}); + }); +} + +function nextPRID(data) { + data.next_pr_id = Number(data.next_pr_id || 1); + return data.next_pr_id++; +} + +function findPR(data, id) { + return (data.prs || []).find((pr) => Number(pr.id) === Number(id)); +} + +function nextPRNoteID(pr) { + pr.next_note_id = Number(pr.next_note_id || 1); + return pr.next_note_id++; +} + +function bumpPRVersion(data, pr) { + const now = new Date().toISOString(); + data.next_pr_version = Number(data.next_pr_version || 1); + pr.version = `${data.next_pr_version++}-${crypto.randomBytes(4).toString('hex')}`; + pr.updated_at = now; + return pr; +} + +function ensurePRVersions(data) { + let changed = false; + for (const pr of data.prs || []) { + if (!pr.version) { + bumpPRVersion(data, pr); + changed = true; + } + } + return changed; +} + +function syncPRs(data, known) { + const knownMap = known && typeof known === 'object' ? known : {}; + return (data.prs || []).filter((pr) => String(pr.version || '') !== String(knownMap[String(pr.id)] || '')); +} + +function countApprovals(pr) { + const latest = new Map(); + for (const review of pr.reviews || []) { + if (review.user) latest.set(review.user, review.state); + } + return Array.from(latest.values()).filter((state) => state === 'approved').length; +} + +async function ensureRepo(repo) { + if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error('repo is required'); + const entry = await loadRepo(repo); + const owners = await loadOwners(); + for (const owner of owners.data.keys || []) { + if (owner.role === 'owner' && !entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) { + entry.data.keys.push(owner); + } + } + return entry; +} + +exports.broker = async (req, res) => { + res.set('content-type', 'application/json'); + if (req.path === '/health' || req.path === '/') { + res.status(200).send(JSON.stringify({ok: true, service: 'bgit-broker', version: brokerVersion})); + return; + } + try { + const body = req.body || {}; + if (req.path === '/owners/upsert' && req.method === 'POST') { + const entry = await loadOwners(); + if (!verifySignature(req, entry)) throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const user = body.user || 'owner'; + const role = normalizeRole(body.role || 'owner'); + if (role !== 'owner') throw new Error('owner bootstrap only accepts owner role'); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/repos/upsert' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = body.admin_user || 'admin'; + const role = normalizeRole(body.role || 'admin'); + if (!validRole(role)) throw new Error('invalid role'); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + audit(entry, {type: 'repo_upsert', user}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix})); + return; + } + if (req.path === '/keys/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + res.status(200).send(JSON.stringify({keys: entry.data.keys})); + return; + } + if (req.path === '/keys/add' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = body.user || 'admin'; + const role = normalizeRole(body.role || 'read'); + if (!validRole(role) || role === 'owner') throw new Error('invalid role'); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const key = String(body.key || '').trim(); + const normalized = normalizeKey(key); + const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); + if (entry.data.keys.some((k) => match(k) && k.role === 'owner')) throw Object.assign(new Error('owners cannot be removed or suspended'), {status: 403}); + if (req.path === '/keys/remove') entry.data.keys = entry.data.keys.filter((k) => !match(k)); + else for (const item of entry.data.keys) if (match(item)) item.suspended = true; + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/owners/transfer' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const target = String(body.key || '').trim(); + const normalized = normalizeKey(target); + const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(target); + let changed = false; + for (const item of entry.data.keys) { + if (match(item)) { + item.role = 'owner'; + item.user = body.user || item.user; + changed = true; + } else if (item.role === 'owner' && normalizeKey(item.public_key) === normalizeKey(key.public_key)) { + item.role = 'admin'; + } + } + if (!changed) throw new Error('target key not found'); + audit(entry, {type: 'owner_transfer', user: body.user || ''}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/protection/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + res.status(200).send(JSON.stringify({protections: entry.data.protections || []})); + return; + } + if (req.path === '/protection/upsert' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); + audit(entry, {type: 'protection_upsert', ref: body.ref}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/protection/remove' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + audit(entry, {type: 'protection_remove', ref: body.ref}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/prs/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + entry.data.refs = entry.data.refs || {}; + const pr = {...(body.pr || {})}; + pr.id = nextPRID(entry.data); + pr.status = 'open'; + pr.author = key.user; + pr.approvals = pr.approvals || 0; + pr.checks = pr.checks || []; + pr.head = entry.data.refs[pr.source] || ''; + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_create', id: pr.id, source: pr.source, target: pr.target, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({pr})); + return; + } + if (req.path === '/prs/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify({prs: await listPRs(entry)})); + return; + } + if (req.path === '/prs/sync' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify(await syncPRRecords(entry, body.known || {}))); + return; + } + if (req.path === '/prs/view' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + res.status(200).send(JSON.stringify({pr})); + return; + } + if (req.path === '/prs/close' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + pr.status = 'closed'; + pr.closed_by = key.user; + pr.closed_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_close', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/comment' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw Object.assign(new Error('comment is required'), {status: 400}); + pr.comments = pr.comments || []; + pr.comments.push({id: nextPRNoteID(pr), user: key.user, body: comment, at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_comment', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/review' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const state = String(body.review || '').trim(); + if (!['approved', 'changes_requested'].includes(state)) throw Object.assign(new Error('unsupported review state'), {status: 400}); + pr.reviews = pr.reviews || []; + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || '').trim(), state, at: new Date().toISOString()}); + pr.approvals = countApprovals(pr); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_review', id: pr.id, user: key.user, state}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/merge' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || !roleAllows(key.role, 'merge')) throw Object.assign(new Error('merge SSH signature required'), {status: 403}); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + if (pr.status !== 'open') throw new Error('pull request is not open'); + const newHash = (entry.data.refs || {})[pr.source] || pr.head; + if (!newHash) throw new Error('pull request source ref has no head'); + const oldHash = (entry.data.refs || {})[pr.target] || zero; + await updateRefCAS(body.repo, pr.target, oldHash, newHash, key, {fromPR: true}); + const repo = await ensurePhysicalRepo(entry); + await writeTextObject(repo, pr.target, newHash + '\n'); + entry.data.refs = entry.data.refs || {}; + entry.data.refs[pr.target] = newHash; + pr.status = 'merged'; + pr.merged_by = key.user; + pr.merged_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + if (body.delete_branch && pr.source && pr.source !== pr.target) { + delete entry.data.refs[pr.source]; + await deleteObject(repo, pr.source); + audit(entry, {type: 'branch_delete', ref: pr.source, from_pr: pr.id, user: key.user}); + } + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/auth/check' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + const operation = body.operation || ''; + const allowed = !!key && roleAllows(key.role, operation); + res.status(200).send(JSON.stringify({allowed, user: key && key.user, role: key && key.role})); + return; + } + if (req.path === '/objects/capability' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const operation = body.operation || 'read'; + const key = operation === 'read' ? requireRead(req, entry) : requireWrite(req, entry); + const repo = await ensurePhysicalRepo(entry); + const capability = body.resumable ? await resumableUpload(repo, body.path) : await signedURL(repo, body.path, operation); + audit(entry, {type: 'capability_issued', operation, path: body.path, user: key.user, role: key.role}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({...capability, bucket: repo.bucket, prefix: repo.prefix, object: objectName(repo, body.path)})); + return; + } + if (req.path === '/objects/read' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const repo = await ensurePhysicalRepo(entry); + const data = await readObject(repo, body.path); + res.status(200).send(JSON.stringify({data})); + return; + } + if (req.path === '/objects/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const repo = await ensurePhysicalRepo(entry); + const paths = await listObjects(repo, body.prefix); + res.status(200).send(JSON.stringify({paths})); + return; + } + if (req.path === '/refs/update' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + res.status(404).send(JSON.stringify({error: 'unknown broker endpoint'})); + } catch (err) { + res.status(err.status || 500).send(JSON.stringify({error: err.message || String(err)})); + } +}; diff --git a/broker/gcp/package.json b/broker/gcp/package.json new file mode 100644 index 0000000..ce9b776 --- /dev/null +++ b/broker/gcp/package.json @@ -0,0 +1 @@ +{"scripts":{"start":"functions-framework --target=broker"},"dependencies":{"@google-cloud/functions-framework":"^3.4.0","@google-cloud/firestore":"^7.10.0","@google-cloud/storage":"^7.16.0","google-auth-library":"^9.15.1"}} diff --git a/broker_assets.go b/broker_assets.go new file mode 100644 index 0000000..cbf8a94 --- /dev/null +++ b/broker_assets.go @@ -0,0 +1,33 @@ +package main + +import ( + "embed" + "os" + "path/filepath" + "strings" +) + +//go:embed broker/gcp/package.json broker/gcp/index.js broker/aws/template.yaml +var brokerAssets embed.FS + +func writeGCPBrokerSource(dir string) error { + for _, name := range []string{"package.json", "index.js"} { + data, err := brokerAssets.ReadFile("broker/gcp/" + name) + if err != nil { + return err + } + body := strings.ReplaceAll(string(data), "{{BROKER_VERSION}}", brokerVersion) + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + return err + } + } + return nil +} + +func awsBrokerCloudFormationTemplate() string { + data, err := brokerAssets.ReadFile("broker/aws/template.yaml") + if err != nil { + return "" + } + return strings.ReplaceAll(string(data), "{{BROKER_VERSION}}", brokerVersion) +} diff --git a/broker_commands.go b/broker_commands.go new file mode 100644 index 0000000..ee483fc --- /dev/null +++ b/broker_commands.go @@ -0,0 +1,1464 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type brokerProfile struct { + Provider string + Name string + Region string + QualifiedName string + BrokerURL string +} + +func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { + return brokerAdminCommandWithInput(cfg, args, os.Stdin, stdout) +} + +func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin keys|owner|protect [args]\n\nCloud IAM administration moved to bgit direct admin.") + } + switch args[0] { + case "keys": + return brokerAdminKeysCommand(cfg, args[1:], stdin, stdout) + case "repo": + return errors.New("repo registration happens during bgit init; use bgit admin keys for user/key administration") + case "owner": + return brokerOwnerCommand(cfg, args[1:], stdout) + case "protect": + return brokerProtectionCommand(cfg, args[1:], stdout) + case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": + return errors.New("cloud IAM administration moved to bgit direct admin") + default: + return fmt.Errorf("unknown admin command %q", args[0]) + } +} + +func brokerInitCommand(args []string, stdin io.Reader, stdout io.Writer) error { + opts, repoName, err := parseBrokerInitArgs(args) + if err != nil { + return err + } + if !opts.noninteractive { + opts.interactive = true + } + if opts.noninteractive { + if strings.TrimSpace(opts.profile) == "" { + return errors.New("init --noninteractive requires --profile PROFILE") + } + if strings.TrimSpace(repoName) == "" { + return errors.New("init --noninteractive requires --repo NAME") + } + } + global, path, err := loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 && opts.interactive { + fmt.Fprint(stdout, "No broker profiles found. Run bgit setup now? [y/N] ") + answer, _ := bufio.NewReader(stdin).ReadString('\n') + if strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes") { + cmd := exec.Command(os.Args[0], "setup", "--config", path) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run bgit setup: %w", err) + } + global, path, err = loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles = brokerProfilesFromGlobalConfig(global) + } + } + if len(profiles) == 0 { + return errors.New("no broker profiles configured; run bgit setup first") + } + target := "." + if opts.directory != "" { + target = opts.directory + } + identityName := "" + identityEmail := "" + if opts.interactive { + initial := initDialogInitialState(target, global, repoName, opts.profile) + identityName = initial.IdentityName + identityEmail = initial.IdentityEmail + result, err := brokerInitPrompt(stdin, stdout, initial, profiles) + if err != nil { + return err + } + if !result.Changed { + fmt.Fprintln(stdout, "No changes made to the repository configuration.") + return nil + } + if result.IdentityChanged && !result.RepoChanged && !result.ProfileChanged { + if err := writeLocalIdentityConfig(target, result.IdentityName, result.IdentityEmail); err != nil { + return err + } + fmt.Fprintln(stdout, "Updated repository identity.") + return nil + } + repoName = result.RepoName + opts.profile = result.ProfileName + identityName = result.IdentityName + identityEmail = result.IdentityEmail + } + if strings.TrimSpace(repoName) == "" { + wd, err := os.Getwd() + if err != nil { + return err + } + repoName = filepath.Base(wd) + ".git" + } + profile, err := selectBrokerProfileForCommand(profiles, opts.profile, opts.region, "bgit init") + if err != nil { + return err + } + if identityName == "" && identityEmail == "" { + identity := initDialogInitialState(target, global, repoName, opts.profile) + identityName = identity.IdentityName + identityEmail = identity.IdentityEmail + } + return initBrokerWorktree(target, repoName, profile, identityName, identityEmail, stdout) +} + +func brokerCloneCommand(args []string, stdin io.Reader, stdout io.Writer) error { + opts, repoName, err := parseBrokerInitArgs(args) + if err != nil { + return err + } + if opts.brokerURL == "" { + brokerURL, parsedRepo, ok, err := parseBrokerCloneURL(repoName) + if err != nil { + return err + } + if ok { + opts.brokerURL = brokerURL + repoName = parsedRepo + } + } + if strings.TrimSpace(repoName) == "" { + return errors.New("usage: bgit clone [directory] [--profile PROFILE]\n bgit clone https://broker.example.com/team/app.git [directory]\n bgit clone --broker https://broker.example.com team/app.git [directory]") + } + if opts.brokerURL != "" { + profile, err := brokerProfileForCloneURL(opts.brokerURL) + if err != nil { + return err + } + return brokerCloneWithProfile(opts, repoName, profile, stdout) + } + global, _, err := loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 { + return errors.New("no broker profiles configured; run bgit setup first") + } + if opts.interactive { + result, err := brokerInitPrompt(stdin, stdout, initDialogInitialState(".", global, repoName, opts.profile), profiles) + if err != nil { + return err + } + repoName = result.RepoName + opts.profile = result.ProfileName + } + profile, err := selectBrokerProfileForCommand(profiles, opts.profile, opts.region, "bgit clone "+repoName) + if err != nil { + return err + } + return brokerCloneWithProfile(opts, repoName, profile, stdout) +} + +func brokerCloneWithProfile(opts brokerInitOptions, repoName string, profile brokerProfile, stdout io.Writer) error { + target := opts.directory + if target == "" { + target = strings.TrimSuffix(filepath.Base(strings.Trim(repoName, "/")), ".git") + } + if err := initBrokerWorktree(target, repoName, profile, "", "", io.Discard); err != nil { + return err + } + if _, err := runGit(target, "fetch", "origin"); err != nil { + return err + } + if _, err := runGit(target, "checkout", "--quiet", "-B", defaultBranch, "origin/"+defaultBranch); err != nil { + _, _ = runGit(target, "checkout", "--quiet", "-B", defaultBranch) + } + fmt.Fprintf(stdout, "Cloned %s into '%s'\n", repoName, target) + return nil +} + +func brokerAdminKeysCommand(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin keys list|add|remove|suspend|import-github [args]") + } + if args[0] != "import-github" { + return sshKeysCommand(cfg, args, stdout) + } + opts, err := parseImportGitHubKeysArgs(args[1:]) + if err != nil { + return err + } + cfg, err = configForBrokerCommand(cfg) + if err != nil { + return err + } + brokerURL, err := brokerURLForCommand(sshSetupOptions{broker: opts.broker}) + if err != nil { + return err + } + keys, err := fetchGitHubPublicKeys(context.Background(), opts.username) + if err != nil { + return err + } + if len(keys) == 0 { + return fmt.Errorf("github user %s has no public SSH keys", opts.username) + } + if !opts.yes { + fmt.Fprintf(stdout, "Import %d key(s) from github:%s as %s? [y/N] ", len(keys), opts.username, opts.role) + answer, _ := bufio.NewReader(stdin).ReadString('\n') + if !strings.EqualFold(strings.TrimSpace(answer), "y") && !strings.EqualFold(strings.TrimSpace(answer), "yes") { + return errors.New("import cancelled") + } + } + if err := brokerAddKeysWithSource(brokerURL, cfg, opts.username, opts.role, "github:"+opts.username, keys); err != nil { + return err + } + fmt.Fprintf(stdout, "imported %d key(s) from github:%s with role %s\n", len(keys), opts.username, opts.role) + return nil +} + +type importGitHubKeysOptions struct { + username string + role string + broker string + yes bool +} + +func parseImportGitHubKeysArgs(args []string) (importGitHubKeysOptions, error) { + opts := importGitHubKeysOptions{role: "read"} + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--role": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.role = normalizeBrokerRole(value) + case "--broker": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.broker = value + case "--yes", "-y": + opts.yes = true + default: + if strings.HasPrefix(arg, "-") { + return opts, fmt.Errorf("unsupported import-github option %s", arg) + } + if opts.username != "" { + return opts, errors.New("import-github accepts exactly one username") + } + opts.username = strings.TrimPrefix(strings.TrimSpace(arg), "@") + } + } + if opts.username == "" { + return opts, errors.New("usage: bgit admin keys import-github [--role ROLE] [--yes]") + } + if !validBrokerRole(opts.role) || opts.role == "owner" { + return opts, fmt.Errorf("invalid import role %q", opts.role) + } + return opts, nil +} + +func fetchGitHubPublicKeys(ctx context.Context, username string) ([]string, error) { + endpoint := "https://github.com/" + username + ".keys" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch %s: %s", endpoint, resp.Status) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return splitPublicKeyLines(string(data)), nil +} + +func configForBrokerCommand(base config) (config, error) { + cfg := base + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + } + if strings.TrimSpace(cfg.brokerURL) == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { + cfg.brokerURL = strings.TrimSpace(string(out)) + } + } + if strings.TrimSpace(cfg.brokerURL) == "" { + return config{}, errors.New("broker URL is required; run bgit setup/init first") + } + if strings.TrimSpace(cfg.logicalRepo) == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.logicalRepo"); err == nil { + cfg.logicalRepo = strings.Trim(strings.TrimSpace(string(out)), "/") + } + } + if cfg.origin == "" { + cfg.origin = originForConfig(cfg) + } + return cfg, nil +} + +type brokerOwnerTransferRequest struct { + Repo brokerRepo `json:"repo"` + User string `json:"user"` + Key string `json:"key"` +} + +func brokerOwnerCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 || args[0] != "transfer" { + return errors.New("usage: bgit admin owner transfer --user USER KEY_OR_FINGERPRINT") + } + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + user := "" + key := "" + for i := 1; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + user = value + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported owner transfer option %s", arg) + } + if key != "" { + return errors.New("owner transfer accepts exactly one key") + } + key = arg + } + } + if user == "" || key == "" { + return errors.New("usage: bgit admin owner transfer --user USER KEY_OR_FINGERPRINT") + } + if err := brokerPost(cfg.brokerURL, "/owners/transfer", brokerOwnerTransferRequest{Repo: repoForBroker(cfg), User: user, Key: key}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "transferred owner role to %s\n", user) + return nil +} + +type brokerProtectionRequest struct { + Repo brokerRepo `json:"repo"` + Ref string `json:"ref"` + RequirePR bool `json:"require_pr"` + AllowOverrides bool `json:"allow_overrides"` +} + +func brokerProtectionCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin protect add|list|remove [ref]") + } + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + switch args[0] { + case "list": + var resp struct { + Protections []brokerProtectionRequest `json:"protections"` + } + if err := brokerPost(cfg.brokerURL, "/protection/list", brokerProtectionRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, protection := range resp.Protections { + mode := "pr-required" + if protection.AllowOverrides { + mode += ",owner-admin-override" + } + fmt.Fprintf(stdout, "%s\t%s\n", protection.Ref, mode) + } + return nil + case "add": + ref := "refs/heads/main" + allowOverrides := false + for _, arg := range args[1:] { + switch arg { + case "--allow-owner-admin-override": + allowOverrides = true + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported protect option %s", arg) + } + ref = normalizeDestinationRef(arg) + } + } + req := brokerProtectionRequest{Repo: repoForBroker(cfg), Ref: ref, RequirePR: true, AllowOverrides: allowOverrides} + if err := brokerPost(cfg.brokerURL, "/protection/upsert", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "protected %s\n", ref) + return nil + case "remove": + if len(args) != 2 { + return errors.New("usage: bgit admin protect remove ") + } + req := brokerProtectionRequest{Repo: repoForBroker(cfg), Ref: normalizeDestinationRef(args[1])} + if err := brokerPost(cfg.brokerURL, "/protection/remove", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "removed protection for %s\n", req.Ref) + return nil + default: + return fmt.Errorf("unknown protect command %q", args[0]) + } +} + +type brokerPullRequest struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Source string `json:"source,omitempty"` + Target string `json:"target,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + Version string `json:"version,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Approvals int `json:"approvals,omitempty"` + Checks []string `json:"checks,omitempty"` + Head string `json:"head,omitempty"` + Comments []brokerPullRequestNote `json:"comments,omitempty"` + Reviews []brokerPullRequestNote `json:"reviews,omitempty"` + MergedBy string `json:"merged_by,omitempty"` + MergedAt string `json:"merged_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` +} + +type brokerPullRequestNote struct { + ID int `json:"id,omitempty"` + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + State string `json:"state,omitempty"` + Source string `json:"source,omitempty"` + At string `json:"at,omitempty"` +} + +type brokerPullRequestRequest struct { + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + PR brokerPullRequest `json:"pr,omitempty"` + Known map[string]string `json:"known,omitempty"` + Merge bool `json:"merge,omitempty"` + DeleteBranch bool `json:"delete_branch,omitempty"` + Comment string `json:"comment,omitempty"` + Review string `json:"review,omitempty"` +} + +func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { + _ = stdin + if len(args) == 0 { + return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close [args]") + } + cfg, err := configForBrokerCommand(config{}) + if err != nil { + return err + } + switch args[0] { + case "create": + return prCreateCommand(cfg, args[1:], stdout) + case "list": + var resp struct { + PRs []brokerPullRequest `json:"prs"` + } + if err := brokerPost(cfg.brokerURL, "/prs/list", brokerPullRequestRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, pr := range resp.PRs { + fmt.Fprintf(stdout, "#%d\t%s\t%s -> %s\t%s\n", pr.ID, pr.Status, pr.Source, pr.Target, pr.Title) + } + return nil + case "view": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + fmt.Fprintf(stdout, "#%d %s\nstatus: %s\nsource: %s\ntarget: %s\napprovals: %d\n", pr.ID, pr.Title, pr.Status, pr.Source, pr.Target, pr.Approvals) + if strings.TrimSpace(pr.Body) != "" { + fmt.Fprintf(stdout, "\n%s\n", pr.Body) + } + return nil + case "close": + id, err := parsePRIDArg(args[1:]) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/close", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "closed PR #%d\n", id) + return nil + case "merge": + id, err := parsePRIDArg(args[1:]) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/merge", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Merge: true}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "merged PR #%d\n", id) + return nil + case "checkout": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + if _, err := runGit(".", "fetch", "origin", pr.Source+":"+pr.Source); err != nil { + return err + } + _, err = runGit(".", "checkout", shortRefName(pr.Source)) + return err + case "diff": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + source := shortRefName(pr.Source) + target := shortRefName(pr.Target) + if _, err := runGit(".", "fetch", "origin", pr.Source+":refs/remotes/origin/"+source, pr.Target+":refs/remotes/origin/"+target); err != nil { + return err + } + out, err := runGit(".", "diff", "refs/remotes/origin/"+target+"..."+"refs/remotes/origin/"+source) + if err != nil { + return err + } + _, err = stdout.Write(out) + return err + default: + return fmt.Errorf("unknown pr command %q", args[0]) + } +} + +func prCreateCommand(cfg config, args []string, stdout io.Writer) error { + pr := brokerPullRequest{Target: "refs/heads/main"} + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--title": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Title = value + case "--body": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Body = value + case "--source": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Source = normalizeDestinationRef(value) + case "--target": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Target = normalizeDestinationRef(value) + default: + return fmt.Errorf("unsupported pr create option %s", arg) + } + } + if pr.Source == "" { + out, err := runGit(".", "branch", "--show-current") + if err != nil { + return err + } + pr.Source = branchRef(strings.TrimSpace(string(out))) + } + if pr.Title == "" { + pr.Title = shortRefName(pr.Source) + " into " + shortRefName(pr.Target) + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + if err := brokerPost(cfg.brokerURL, "/prs/create", brokerPullRequestRequest{Repo: repoForBroker(cfg), PR: pr}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "created PR #%d %s\n", resp.PR.ID, resp.PR.Title) + return nil +} + +func brokerGetPullRequest(cfg config, args []string) (brokerPullRequest, error) { + id, err := parsePRIDArg(args) + if err != nil { + return brokerPullRequest{}, err + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + if err := brokerPost(cfg.brokerURL, "/prs/view", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, &resp); err != nil { + return brokerPullRequest{}, err + } + return resp.PR, nil +} + +func parsePRIDArg(args []string) (int, error) { + if len(args) != 1 { + return 0, errors.New("PR command requires exactly one PR id") + } + id := parsePositiveInt(strings.TrimPrefix(args[0], "#")) + if id <= 0 { + return 0, fmt.Errorf("invalid PR id %q", args[0]) + } + return id, nil +} + +type brokerInitOptions struct { + interactive bool + noninteractive bool + profile string + region string + repo string + brokerURL string + configPath string + directory string +} + +func parseBrokerInitArgs(args []string) (brokerInitOptions, string, error) { + var opts brokerInitOptions + var rest []string + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--interactive": + opts.interactive = true + case "--noninteractive": + opts.noninteractive = true + case "--profile": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.profile = value + case "--region": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.region = value + case "--repo": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.repo = value + case "--broker": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.brokerURL = value + case "--config": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.configPath = expandHome(value) + default: + if strings.HasPrefix(arg, "-") { + return opts, "", fmt.Errorf("unsupported init option %s", arg) + } + rest = append(rest, arg) + } + } + if opts.interactive && opts.noninteractive { + return opts, "", errors.New("init accepts either --interactive or --noninteractive, not both") + } + if opts.repo != "" { + switch len(rest) { + case 0: + return opts, opts.repo, nil + case 1: + opts.directory = rest[0] + return opts, opts.repo, nil + default: + return opts, "", errors.New("init accepts at most one directory when --repo is set") + } + } + switch len(rest) { + case 0: + return opts, opts.repo, nil + case 1: + return opts, firstNonEmpty(opts.repo, rest[0]), nil + case 2: + opts.directory = rest[1] + return opts, firstNonEmpty(opts.repo, rest[0]), nil + default: + return opts, "", errors.New("init accepts at most repository name and optional directory") + } +} + +func parseBrokerCloneURL(raw string) (string, string, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" || (!strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://")) { + return "", "", false, nil + } + parsed, err := url.Parse(raw) + if err != nil { + return "", "", true, fmt.Errorf("parse broker clone URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", true, fmt.Errorf("unsupported broker clone URL scheme %q", parsed.Scheme) + } + if parsed.Host == "" { + return "", "", true, errors.New("broker clone URL must include a host") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", "", true, errors.New("broker clone URL must not include query parameters or a fragment") + } + repoName := strings.Trim(parsed.Path, "/") + if repoName == "" { + return "", "", true, errors.New("broker clone URL must include a logical repository path") + } + return parsed.Scheme + "://" + parsed.Host, repoName, true, nil +} + +func brokerProfileForCloneURL(brokerURL string) (brokerProfile, error) { + brokerURL = strings.TrimRight(strings.TrimSpace(brokerURL), "/") + if brokerURL == "" { + return brokerProfile{}, errors.New("--broker requires a broker URL") + } + parsed, err := url.Parse(brokerURL) + if err != nil { + return brokerProfile{}, fmt.Errorf("parse broker URL: %w", err) + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return brokerProfile{}, errors.New("--broker must be an http(s) URL") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return brokerProfile{}, errors.New("--broker must not include query parameters or a fragment") + } + return brokerProfile{ + Provider: "gcs", + Name: parsed.Host, + QualifiedName: "broker:" + brokerURL, + BrokerURL: brokerURL, + }, nil +} + +func loadGlobalConfigForInit(path string) (globalConfig, string, error) { + var err error + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return globalConfig{}, "", err + } + } + cfg, err := readGlobalConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return globalConfig{Version: globalConfigVersion}, path, nil + } + return globalConfig{}, path, err + } + return cfg, path, nil +} + +func brokerProfilesFromGlobalConfig(cfg globalConfig) []brokerProfile { + var profiles []brokerProfile + for _, profile := range cfg.GCPProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + name := "gcp:" + profile.Name + "/" + region.Name + profiles = append(profiles, brokerProfile{Provider: "gcs", Name: profile.Name, Region: region.Name, QualifiedName: name, BrokerURL: region.BrokerURL}) + } + } + for _, profile := range cfg.AWSProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + name := "aws:" + profile.Name + "/" + region.Name + profiles = append(profiles, brokerProfile{Provider: "s3", Name: profile.Name, Region: region.Name, QualifiedName: name, BrokerURL: region.BrokerURL}) + } + } + return profiles +} + +func selectBrokerProfile(profiles []brokerProfile, name string) (brokerProfile, error) { + return selectBrokerProfileForCommand(profiles, name, "", "bgit") +} + +func selectBrokerProfileForCommand(profiles []brokerProfile, name, region, command string) (brokerProfile, error) { + if strings.TrimSpace(name) == "" { + if len(profiles) == 1 { + return profiles[0], nil + } + return brokerProfile{}, errors.New("multiple broker profiles configured; pass --profile") + } + name = strings.TrimSpace(name) + region = strings.TrimSpace(region) + var matches []brokerProfile + for _, profile := range profiles { + if region != "" && profile.Region != region { + continue + } + if brokerProfileNameMatches(profile, name) { + matches = append(matches, profile) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return brokerProfile{}, ambiguousBrokerProfileError(name, command, matches) + } + return brokerProfile{}, fmt.Errorf("broker profile %q not found", name) +} + +func brokerProfileNameMatches(profile brokerProfile, name string) bool { + providerName := providerProfileName(profile.Provider) + candidates := []string{ + profile.QualifiedName, + profile.Name, + profile.Name + "." + profile.Region, + providerName + ":" + profile.Name, + providerName + ":" + profile.Name + "." + profile.Region, + providerName + ":" + profile.Name + "/" + profile.Region, + } + for _, candidate := range candidates { + if name == candidate { + return true + } + } + return false +} + +func ambiguousBrokerProfileError(name, command string, matches []brokerProfile) error { + var b strings.Builder + fmt.Fprintf(&b, "broker profile %q is ambiguous.\nSpecify the region you want to use:\n", name) + for _, profile := range matches { + fmt.Fprintf(&b, " %s --profile %s.%s\n", command, profile.Name, profile.Region) + } + return errors.New(strings.TrimRight(b.String(), "\n")) +} + +func providerProfileName(provider string) string { + if provider == "s3" { + return "aws" + } + return "gcp" +} + +type initDialogConfig struct { + RepoName string + ProfileName string + IdentityName string + IdentityEmail string + Existing bool +} + +type initDialogResult struct { + RepoName string + ProfileName string + IdentityName string + IdentityEmail string + Changed bool + RepoChanged bool + ProfileChanged bool + IdentityChanged bool +} + +func brokerInitPrompt(stdin io.Reader, stdout io.Writer, initial initDialogConfig, profiles []brokerProfile) (initDialogResult, error) { + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runInitDialogWithRaw(reader, stdin, stdout, initial, profiles) +} + +type initDialogState struct { + repoName string + initialRepoName string + profileName string + initialProfileName string + identityName string + initialIdentityName string + identityEmail string + initialIdentityEmail string + existing bool + profiles []brokerProfile + selectedProfile int + initialProfile int + cursor int + button int + editingField int + editOriginal string + message string +} + +func runInitDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, initial initDialogConfig, profiles []brokerProfile) (initDialogResult, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return initDialogResult{}, err + } + defer restore() + selectedProfile := initDialogSelectedProfile(profiles, initial.ProfileName) + state := initDialogState{ + repoName: firstNonEmpty(strings.TrimSpace(initial.RepoName), defaultInitRepoName()), + initialRepoName: firstNonEmpty(strings.TrimSpace(initial.RepoName), defaultInitRepoName()), + profileName: strings.TrimSpace(initial.ProfileName), + initialProfileName: strings.TrimSpace(initial.ProfileName), + identityName: firstNonEmpty(strings.TrimSpace(initial.IdentityName), defaultBucketGitIdentityName), + initialIdentityName: firstNonEmpty(strings.TrimSpace(initial.IdentityName), defaultBucketGitIdentityName), + identityEmail: firstNonEmpty(strings.TrimSpace(initial.IdentityEmail), defaultBucketGitIdentityEmail()), + initialIdentityEmail: firstNonEmpty(strings.TrimSpace(initial.IdentityEmail), defaultBucketGitIdentityEmail()), + existing: initial.Existing, + profiles: profiles, + selectedProfile: selectedProfile, + initialProfile: selectedProfile, + button: -1, + editingField: -1, + } + for { + fmt.Fprint(stdout, renderInitDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return initDialogResult{}, errors.New("init canceled") + } + return initDialogResult{}, err + } + switch b { + case 0x03: + return initDialogResult{}, errors.New("init canceled") + case 0x04: + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + continue + } + if result, ok := state.deploy(); ok { + return result, nil + } + case '\r', '\n': + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + state.message = "" + continue + } + if result, ok := state.activate(); ok { + return result, nil + } else if state.button == 1 { + return initDialogResult{}, errors.New("init canceled") + } + case ' ': + if state.editingField >= 0 { + state.appendFieldByte(b) + } else if result, ok := state.activate(); ok { + return result, nil + } + case '\t': + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + } + state.tab() + case 0x7f, 0x08: + if state.editingField >= 0 { + state.backspaceField() + } + case 0x1b: + if state.editingField >= 0 { + state.setFieldValue(state.editingField, state.editOriginal) + state.editingField = -1 + state.editOriginal = "" + state.message = "" + continue + } + next, err := reader.ReadByte() + if err != nil { + return initDialogResult{}, errors.New("init canceled") + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return initDialogResult{}, errors.New("init canceled") + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + return initDialogResult{}, errors.New("init canceled") + default: + if state.editingField >= 0 && b >= 32 && b <= 126 { + state.appendFieldByte(b) + } + } + } +} + +func defaultInitRepoName() string { + wd, err := os.Getwd() + if err != nil { + return "repo.git" + } + name := strings.TrimSpace(filepath.Base(wd)) + if name == "" || name == "." || name == string(filepath.Separator) { + return "repo.git" + } + if !strings.HasSuffix(name, ".git") { + name += ".git" + } + return name +} + +func initDialogInitialState(target string, global globalConfig, repoName, profileName string) initDialogConfig { + initial := initDialogConfig{ + RepoName: firstNonEmpty(strings.TrimSpace(repoName), defaultInitRepoName()), + ProfileName: strings.TrimSpace(profileName), + IdentityName: firstNonEmpty(strings.TrimSpace(global.Identity.Name), defaultBucketGitIdentityName), + IdentityEmail: firstNonEmpty(strings.TrimSpace(global.Identity.Email), defaultBucketGitIdentityEmail()), + } + gitDir := filepath.Join(target, ".git") + configPath := filepath.Join(gitDir, "config") + if _, err := os.Stat(configPath); err == nil { + initial.Existing = true + } + cfg, err := readLocalConfigFile(configPath) + if err != nil { + return initial + } + if value, ok := cfg.get("bucketgit.logicalRepo"); ok && strings.TrimSpace(repoName) == "" { + initial.RepoName = strings.TrimSpace(value) + } + if value, ok := cfg.get("bucketgit.profile"); ok && strings.TrimSpace(profileName) == "" { + initial.ProfileName = strings.TrimSpace(value) + } + if value, ok := cfg.get("user.name"); ok && strings.TrimSpace(value) != "" { + initial.IdentityName = strings.TrimSpace(value) + } + if value, ok := cfg.get("user.email"); ok && strings.TrimSpace(value) != "" { + initial.IdentityEmail = strings.TrimSpace(value) + } + return initial +} + +func initDialogSelectedProfile(profiles []brokerProfile, profileName string) int { + if strings.TrimSpace(profileName) == "" { + if len(profiles) == 1 { + return 0 + } + return -1 + } + for i, profile := range profiles { + if brokerProfileNameMatches(profile, strings.TrimSpace(profileName)) { + return i + } + } + return -1 +} + +func (s initDialogState) rows() int { + return 3 + len(s.profiles) +} + +func (s *initDialogState) up() { + if s.editingField >= 0 { + return + } + s.button = -1 + s.message = "" + if s.rows() == 0 { + return + } + if s.cursor == 0 { + s.cursor = s.rows() - 1 + return + } + s.cursor-- +} + +func (s *initDialogState) down() { + if s.editingField >= 0 { + return + } + s.button = -1 + s.message = "" + if s.rows() == 0 { + return + } + s.cursor = (s.cursor + 1) % s.rows() +} + +func (s *initDialogState) tab() { + if s.editingField >= 0 { + s.editingField = -1 + s.editOriginal = "" + } + s.message = "" + if s.button == 1 { + s.button = -1 + s.cursor = 0 + return + } + if s.button < 0 { + s.button = 0 + return + } + s.button = (s.button + 1) % 2 +} + +func (s *initDialogState) activate() (initDialogResult, bool) { + if s.button == 0 { + return s.deploy() + } + if s.button == 1 { + return initDialogResult{}, false + } + if s.cursor >= 0 && s.cursor <= 2 { + s.editingField = s.cursor + s.editOriginal = s.fieldValue(s.cursor) + s.message = "" + return initDialogResult{}, false + } + idx := s.cursor - 3 + if idx >= 0 && idx < len(s.profiles) { + s.selectedProfile = idx + s.profileName = s.profiles[idx].QualifiedName + } + return initDialogResult{}, false +} + +func (s *initDialogState) deploy() (initDialogResult, bool) { + repo := strings.TrimSpace(s.repoName) + if repo == "" { + s.message = "Enter a repository name before OK." + return initDialogResult{}, false + } + if email := strings.TrimSpace(s.identityEmail); email != "" && !identityEmailPattern.MatchString(email) { + s.message = "Email address looks invalid." + return initDialogResult{}, false + } + result := s.result() + if !result.Changed { + return result, true + } + if (result.RepoChanged || result.ProfileChanged) && (s.selectedProfile < 0 || s.selectedProfile >= len(s.profiles)) { + s.message = "Select a profile before OK." + return initDialogResult{}, false + } + return result, true +} + +func (s initDialogState) result() initDialogResult { + profileName := strings.TrimSpace(s.profileName) + if s.selectedProfile >= 0 && s.selectedProfile < len(s.profiles) { + profileName = s.profiles[s.selectedProfile].QualifiedName + } + result := initDialogResult{ + RepoName: strings.TrimSpace(s.repoName), + ProfileName: profileName, + IdentityName: strings.TrimSpace(s.identityName), + IdentityEmail: strings.TrimSpace(s.identityEmail), + } + result.RepoChanged = result.RepoName != strings.TrimSpace(s.initialRepoName) + result.ProfileChanged = result.ProfileName != strings.TrimSpace(s.initialProfileName) + result.IdentityChanged = result.IdentityName != strings.TrimSpace(s.initialIdentityName) || + result.IdentityEmail != strings.TrimSpace(s.initialIdentityEmail) + result.Changed = result.RepoChanged || result.ProfileChanged || result.IdentityChanged + if !s.existing && result.ProfileName != "" { + result.Changed = true + } + return result +} + +func (s initDialogState) fieldValue(row int) string { + switch row { + case 1: + return s.identityName + case 2: + return s.identityEmail + default: + return s.repoName + } +} + +func (s *initDialogState) setFieldValue(row int, value string) { + switch row { + case 1: + s.identityName = value + case 2: + s.identityEmail = value + default: + s.repoName = value + } +} + +func (s *initDialogState) appendFieldByte(b byte) { + s.message = "" + value := s.fieldValue(s.editingField) + if len(value) >= 80 { + return + } + s.setFieldValue(s.editingField, value+string(b)) +} + +func (s *initDialogState) backspaceField() { + s.message = "" + value := s.fieldValue(s.editingField) + if len(value) == 0 { + return + } + s.setFieldValue(s.editingField, value[:len(value)-1]) +} + +func renderInitDialogFrame(state initDialogState, rawMode bool) string { + rendered := renderInitDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderInitDialogWithStyle(state initDialogState, style bool) string { + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT INIT |", + "+------------------------------------------------------------+", + setupDialogRow("Configure repository"), + "| |", + ) + inputActive := state.editingField == 0 + inputStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 0) + if style && inputActive { + inputStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Repository [%s]", initDialogMarker(state, 0), initDialogInputValue(state.repoName, 38, inputActive, style)), inputStyle)) + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Identity", setupDialogSectionStyle(style, state.button < 0 && (state.cursor == 1 || state.cursor == 2)))) + nameActive := state.editingField == 1 + nameStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 1) + if style && nameActive { + nameStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Name [%s]", initDialogMarker(state, 1), initDialogInputValue(state.identityName, 43, nameActive, style)), nameStyle)) + emailActive := state.editingField == 2 + emailStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 2) + if style && emailActive { + emailStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Email [%s]", initDialogMarker(state, 2), initDialogInputValue(state.identityEmail, 43, emailActive, style)), emailStyle)) + if state.usesDefaultIdentity() { + lines = append(lines, setupDialogRowStyled(" Configure name/email with bgit setup or bgit config.", setupDialogANSI(style, "33"))) + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Profiles", setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + for i, profile := range state.profiles { + cursor := i + 3 + marker := initDialogMarker(state, cursor) + checked := " " + if state.selectedProfile == i { + checked = "x" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %-50s", marker, checked, profile.QualifiedName), setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + } + if len(state.profiles) == 0 { + lines = append(lines, setupDialogRowStyled(" no profiles configured; run bgit setup", setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Enter edits/saves field Space selects profile"), + setupDialogRow("Tab fields/buttons Ctrl-D OK Esc cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func initDialogMarker(state initDialogState, row int) string { + if state.button < 0 && state.cursor == row { + return ">" + } + return " " +} + +func (s initDialogState) usesDefaultIdentity() bool { + return strings.TrimSpace(s.identityName) == defaultBucketGitIdentityName || + strings.TrimSpace(s.identityEmail) == defaultBucketGitIdentityEmail() +} + +func initDialogInputValue(value string, width int, active, style bool) string { + _ = style + if active { + if len(value) >= width { + return value[len(value)-width+1:] + "|" + } + value += "|" + } + if len(value) > width { + return value[len(value)-width:] + } + return value + strings.Repeat(" ", width-len(value)) +} + +func parsePositiveInt(value string) int { + n := 0 + for _, ch := range value { + if ch < '0' || ch > '9' { + return 0 + } + n = n*10 + int(ch-'0') + } + return n +} + +func initBrokerWorktree(target, repoName string, profile brokerProfile, identityName, identityEmail string, stdout io.Writer) error { + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + if err := os.MkdirAll(absTarget, 0o755); err != nil { + return err + } + if _, err := runGit(absTarget, "init", "--initial-branch", defaultBranch); err != nil { + if _, fallbackErr := runGit(absTarget, "init"); fallbackErr != nil { + return err + } + } + repoName = strings.Trim(repoName, "/") + if !strings.HasSuffix(repoName, ".git") { + repoName += ".git" + } + remoteURL := fmt.Sprintf("git@%s:%s", defaultSSHHost, repoName) + sshCommand := "bgit ssh" + if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" { + sshCommand = exe + " ssh" + } + pairs := [][]string{ + {"bucketgit.broker", profile.BrokerURL}, + {"bucketgit.profile", profile.QualifiedName}, + {"bucketgit.region", profile.Region}, + {"bucketgit.provider", profile.Provider}, + {"bucketgit.logicalRepo", repoName}, + {"core.sshCommand", sshCommand}, + } + if strings.TrimSpace(identityName) != "" { + pairs = append(pairs, []string{"user.name", strings.TrimSpace(identityName)}) + } + if strings.TrimSpace(identityEmail) != "" { + pairs = append(pairs, []string{"user.email", strings.TrimSpace(identityEmail)}) + } + for _, pair := range pairs { + if _, err := runGit(absTarget, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } + if err := setGitOrigin(absTarget, remoteURL); err != nil { + return err + } + if err := setGitBranchTracking(absTarget, defaultBranch, "origin"); err != nil { + return err + } + if err := brokerUpsertLogicalRepo(profile.BrokerURL, profile.Provider, repoName); err != nil { + fmt.Fprintf(stdout, "broker repo registration skipped: %v\n", err) + } + fmt.Fprintf(stdout, "Initialized broker-backed BucketGit repository in %s/\n", filepath.Join(absTarget, ".git")) + fmt.Fprintf(stdout, "configured origin %s\n", remoteURL) + return nil +} + +func writeLocalIdentityConfig(target, name, email string) error { + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(absTarget, ".git")); errors.Is(err, os.ErrNotExist) { + if _, err := runGit(absTarget, "init", "--initial-branch", defaultBranch); err != nil { + if _, fallbackErr := runGit(absTarget, "init"); fallbackErr != nil { + return err + } + } + } + if strings.TrimSpace(name) != "" { + if _, err := runGit(absTarget, "config", "--local", "user.name", strings.TrimSpace(name)); err != nil { + return err + } + } + if strings.TrimSpace(email) != "" { + if _, err := runGit(absTarget, "config", "--local", "user.email", strings.TrimSpace(email)); err != nil { + return err + } + } + return nil +} diff --git a/broker_commands_test.go b/broker_commands_test.go new file mode 100644 index 0000000..1c36f4b --- /dev/null +++ b/broker_commands_test.go @@ -0,0 +1,738 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "project-id", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "app") + var stdout bytes.Buffer + err := brokerInitCommand([]string{"--noninteractive", "--repo", "team/app", target, "--profile", "gcp:work/europe-west1", "--config", configPath}, strings.NewReader(""), &stdout) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "bucketgit.broker": "https://broker.example.test", + "bucketgit.profile": "gcp:work/europe-west1", + "bucketgit.region": "europe-west1", + "bucketgit.logicalRepo": "team/app.git", + "branch.main.remote": "origin", + "branch.main.merge": "refs/heads/main", + } { + out, err := runGit(target, "config", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if strings.TrimSpace(string(out)) != want { + t.Fatalf("%s = %q, want %q", key, strings.TrimSpace(string(out)), want) + } + } + out, err := runGit(target, "config", "--get", "core.sshCommand") + if err != nil { + t.Fatalf("core.sshCommand: %v", err) + } + if got := strings.TrimSpace(string(out)); !strings.HasSuffix(got, " ssh") { + t.Fatalf("core.sshCommand = %q", got) + } + remote, err := runGit(target, "remote", "get-url", "origin") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(remote)) != "git@git.bucketgit.com:team/app.git" { + t.Fatalf("origin = %q", strings.TrimSpace(string(remote))) + } +} + +func TestInitBrokerWorktreeOmitsIdentityWhenUnset(t *testing.T) { + target := filepath.Join(t.TempDir(), "app") + err := initBrokerWorktree(target, "team/app", brokerProfile{ + Provider: "gcs", + QualifiedName: "broker:https://broker.example.test", + BrokerURL: "", + }, "", "", io.Discard) + if err != nil { + t.Fatal(err) + } + if out, err := runGit(target, "config", "--local", "--get", "user.name"); err == nil { + t.Fatalf("user.name should not be set, got %q", strings.TrimSpace(string(out))) + } + if out, err := runGit(target, "config", "--local", "--get", "user.email"); err == nil { + t.Fatalf("user.email should not be set, got %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerInitNoninteractiveRequiresProfileAndRepo(t *testing.T) { + err := brokerInitCommand([]string{"--noninteractive", "--repo", "team/app"}, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "requires --profile") { + t.Fatalf("err = %v", err) + } + err = brokerInitCommand([]string{"--noninteractive", "--profile", "work"}, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "requires --repo") { + t.Fatalf("err = %v", err) + } +} + +func TestAdminKeysListUsesLogicalBrokerRepo(t *testing.T) { + target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/keys/list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + var req brokerRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Repo.Logical != "team/app.git" { + t.Fatalf("logical repo = %q", req.Repo.Logical) + } + _, _ = w.Write([]byte(`{"keys":[{"user":"owner","role":"owner","public_key":"ssh-ed25519 AAAA owner"}]}`)) + }) + defer server.Close() + + var stdout bytes.Buffer + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + defer os.Chdir(previous) + + if err := brokerAdminKeysCommand(config{}, []string{"list"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "owner\towner\tactive") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestTopLevelBrokerInitForwardsGlobalProfile(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "project-id", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://aws-broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "app") + var stdout bytes.Buffer + err := run([]string{"init", "--noninteractive", "--repo", "team/app", target, "--config", configPath, "--profile", "gcp:work/europe-west1"}, strings.NewReader(""), &stdout, ioDiscard{}) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "bucketgit.profile") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "gcp:work/europe-west1" { + t.Fatalf("profile = %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerProfileAmbiguousBareNameSuggestsRegionQualifiedNames(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + QualifiedName: "gcp:work/us-central1", + BrokerURL: "https://us.example.test", + }, { + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://eu.example.test", + }} + _, err := selectBrokerProfileForCommand(profiles, "work", "", "bgit push") + if err == nil { + t.Fatal("expected ambiguous profile error") + } + if !strings.Contains(err.Error(), `broker profile "work" is ambiguous`) || + !strings.Contains(err.Error(), "bgit push --profile work.us-central1") || + !strings.Contains(err.Error(), "bgit push --profile work.europe-west1") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerProfileBareNameWithRegionSelectsProfile(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + QualifiedName: "gcp:work/us-central1", + BrokerURL: "https://us.example.test", + }, { + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://eu.example.test", + }} + got, err := selectBrokerProfileForCommand(profiles, "work", "europe-west1", "bgit push") + if err != nil { + t.Fatal(err) + } + if got.Region != "europe-west1" || got.BrokerURL != "https://eu.example.test" { + t.Fatalf("profile = %#v", got) + } +} + +func TestExplicitBrokerProfileSelectionUsesRegionForDataPlaneCommand(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work", gcloudConfigurationExplicit: true, region: "europe-west1"} + if err := applyExplicitBrokerProfileSelection(&cfg, "push"); err != nil { + t.Fatal(err) + } + if cfg.brokerURL != "https://eu.example.test" || cfg.gcloudConfiguration != "gcp:work/europe-west1" { + t.Fatalf("cfg = %#v", cfg) + } +} + +func TestExplicitBrokerProfileSelectionRejectsAmbiguousDataPlaneProfile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work", gcloudConfigurationExplicit: true} + err := applyExplicitBrokerProfileSelection(&cfg, "push") + if err == nil { + t.Fatal("expected ambiguous profile error") + } + if !strings.Contains(err.Error(), "bgit push --profile work.us-central1") || + !strings.Contains(err.Error(), "bgit push --profile work.europe-west1") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerProfileDotRegionSelectsProfile(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "s3", + Name: "prod", + Region: "us-east-1", + QualifiedName: "aws:prod/us-east-1", + BrokerURL: "https://us.example.test", + }, { + Provider: "s3", + Name: "prod", + Region: "eu-west-1", + QualifiedName: "aws:prod/eu-west-1", + BrokerURL: "https://eu.example.test", + }} + got, err := selectBrokerProfileForCommand(profiles, "prod.eu-west-1", "", "bgit push") + if err != nil { + t.Fatal(err) + } + if got.Region != "eu-west-1" { + t.Fatalf("profile = %#v", got) + } +} + +func TestParseBrokerCloneURL(t *testing.T) { + brokerURL, repo, ok, err := parseBrokerCloneURL("https://broker.example.test/team/app.git") + if err != nil { + t.Fatal(err) + } + if !ok || brokerURL != "https://broker.example.test" || repo != "team/app.git" { + t.Fatalf("brokerURL=%q repo=%q ok=%v", brokerURL, repo, ok) + } +} + +func TestBrokerProfileForCloneURL(t *testing.T) { + profile, err := brokerProfileForCloneURL("https://broker.example.test/") + if err != nil { + t.Fatal(err) + } + if profile.BrokerURL != "https://broker.example.test" || + profile.QualifiedName != "broker:https://broker.example.test" || + profile.Provider != "gcs" { + t.Fatalf("profile = %#v", profile) + } +} + +func TestBrokerInitInteractivePromptsForRepoAndProfile(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "repo") + var stdout bytes.Buffer + err := brokerInitCommand([]string{"--config", configPath, "--profile", "aws:prod/eu-west-1", "ignored", target}, strings.NewReader("\x04"), &stdout) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "bucketgit.profile") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "aws:prod/eu-west-1" { + t.Fatalf("profile = %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerInitInteractiveIdentityOnlyUpdatesRepoConfig(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "repo") + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.profile", "gcp:work/europe-west1"}, + {"config", "user.name", "Repo User"}, + {"config", "user.email", "old@example.com"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + var stdout bytes.Buffer + input := strings.NewReader("\x1b[B\x1b[B\n" + strings.Repeat("\x7f", len("old@example.com")) + "new@example.com\n\x04") + err := brokerInitCommand([]string{"--config", configPath, "--repo", "team/app.git", target}, input, &stdout) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "user.email") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "new@example.com" { + t.Fatalf("user.email = %q", strings.TrimSpace(string(out))) + } + if !strings.Contains(stdout.String(), "Updated repository identity.") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestInitDialogRendersRepoInputAndProfiles(t *testing.T) { + rendered := renderInitDialogWithStyle(initDialogState{ + repoName: "app.git", + profiles: []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + selectedProfile: 0, + }, false) + for _, want := range []string{"BUCKETGIT INIT", "Repository [app.git", "[x] gcp:work/europe-west1", "[ OK ]"} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered missing %q:\n%s", want, rendered) + } + } +} + +func TestInitDialogRendersIdentityAndDefaultWarning(t *testing.T) { + rendered := renderInitDialogWithStyle(initDialogState{ + repoName: "app.git", + identityName: defaultBucketGitIdentityName, + identityEmail: defaultBucketGitIdentityEmail(), + initialIdentityName: defaultBucketGitIdentityName, + initialIdentityEmail: defaultBucketGitIdentityEmail(), + profiles: nil, + selectedProfile: -1, + initialProfile: -1, + editingField: -1, + }, false) + for _, want := range []string{"Identity", "Name [BucketGit Client", "Email [", "Configure name/email with bgit setup or bgit config."} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered missing %q:\n%s", want, rendered) + } + } +} + +func TestInitDialogExistingNoChangesReturnsUnchanged(t *testing.T) { + state := initDialogState{ + repoName: "app.git", + initialRepoName: "app.git", + profileName: "gcp:work/europe-west1", + initialProfileName: "gcp:work/europe-west1", + identityName: "Dennis Example", + initialIdentityName: "Dennis Example", + identityEmail: "dennis@example.com", + initialIdentityEmail: "dennis@example.com", + existing: true, + profiles: []brokerProfile{{QualifiedName: "gcp:work/europe-west1"}}, + selectedProfile: 0, + initialProfile: 0, + } + result, ok := state.deploy() + if !ok { + t.Fatal("deploy rejected unchanged existing config") + } + if result.Changed { + t.Fatalf("changed = true: %#v", result) + } +} + +func TestInitDialogFreshNoProfileNoChangesReturnsUnchanged(t *testing.T) { + state := initDialogState{ + repoName: "foo.git", + initialRepoName: "foo.git", + identityName: "Dennis Vink", + initialIdentityName: "Dennis Vink", + identityEmail: "hi@bucketgit.com", + initialIdentityEmail: "hi@bucketgit.com", + existing: false, + profiles: []brokerProfile{{QualifiedName: "gcp:work/europe-west1"}}, + selectedProfile: -1, + initialProfile: -1, + } + result, ok := state.deploy() + if !ok { + t.Fatalf("deploy rejected unchanged fresh dialog: %q", state.message) + } + if result.Changed { + t.Fatalf("changed = true: %#v", result) + } +} + +func TestInitDialogExistingIdentityOnlyChangeDoesNotRequireProfile(t *testing.T) { + state := initDialogState{ + repoName: "app.git", + initialRepoName: "app.git", + identityName: "Dennis Example", + initialIdentityName: "Dennis Example", + identityEmail: "new@example.com", + initialIdentityEmail: "old@example.com", + existing: true, + selectedProfile: -1, + initialProfile: -1, + } + result, ok := state.deploy() + if !ok { + t.Fatal("deploy rejected identity-only change") + } + if !result.IdentityChanged || result.ProfileChanged || result.RepoChanged { + t.Fatalf("result = %#v", result) + } +} + +func TestInitDialogInitialStateUsesRepoThenGlobalIdentity(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "repo") + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.profile", "gcp:work/europe-west1"}, + {"config", "user.name", "Repo User"}, + {"config", "user.email", "repo@example.com"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + initial := initDialogInitialState(target, globalConfig{Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}}, "", "") + if !initial.Existing || initial.RepoName != "team/app.git" || initial.ProfileName != "gcp:work/europe-west1" || + initial.IdentityName != "Repo User" || initial.IdentityEmail != "repo@example.com" { + t.Fatalf("initial = %#v", initial) + } + fresh := initDialogInitialState(filepath.Join(root, "fresh"), globalConfig{Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}}, "new.git", "") + if fresh.Existing || fresh.IdentityName != "Global User" || fresh.IdentityEmail != "global@example.com" { + t.Fatalf("fresh = %#v", fresh) + } +} + +func TestInitDialogEditsRepoAndSelectsProfile(t *testing.T) { + var stdout bytes.Buffer + result, err := runInitDialogWithRaw( + bufio.NewReader(strings.NewReader("\nbar\n\x1b[B\x1b[B\x1b[B \x04")), + strings.NewReader(""), + &stdout, + initDialogConfig{RepoName: "foo"}, + []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + ) + if err != nil { + t.Fatal(err) + } + if result.RepoName != "foobar" || result.ProfileName != "gcp:work/europe-west1" { + t.Fatalf("repo=%q profile=%q", result.RepoName, result.ProfileName) + } + if !strings.Contains(stdout.String(), "BUCKETGIT INIT") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestInitDialogEscapeRevertsRepoEdit(t *testing.T) { + var stdout bytes.Buffer + result, err := runInitDialogWithRaw( + bufio.NewReader(strings.NewReader("\nbar\x1b\x1b[B\x1b[B\x1b[B \x04")), + strings.NewReader(""), + &stdout, + initDialogConfig{RepoName: "foo"}, + []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + ) + if err != nil { + t.Fatal(err) + } + if result.RepoName != "foo" || result.ProfileName != "gcp:work/europe-west1" { + t.Fatalf("repo=%q profile=%q", result.RepoName, result.ProfileName) + } +} + +func TestTopLevelPushWithoutBrokerOrBucketShowsOriginHint(t *testing.T) { + err := run([]string{"push"}, strings.NewReader(""), &bytes.Buffer{}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "No configured push destination") { + t.Fatalf("err = %v", err) + } +} + +func TestAdminCloudIAMMovedToDirect(t *testing.T) { + err := brokerAdminCommand(config{}, []string{"grant-read", "user@example.com"}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "bgit direct admin") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerAdminProtectAndPRCommandsUseBroker(t *testing.T) { + target, server, requests := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/protection/upsert", "/prs/merge", "/prs/close": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/protection/list": + _, _ = w.Write([]byte(`{"protections":[{"ref":"refs/heads/main","require_pr":true,"allow_overrides":true}]}`)) + case "/prs/create": + var req brokerPullRequestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.PR.Source != "refs/heads/main" || req.PR.Target != "refs/heads/main" { + t.Fatalf("create PR req = %#v", req.PR) + } + _, _ = w.Write([]byte(`{"pr":{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open"}}`)) + case "/prs/list": + _, _ = w.Write([]byte(`{"prs":[{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open"}]}`)) + case "/prs/view": + _, _ = w.Write([]byte(`{"pr":{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open","approvals":0}}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }) + defer server.Close() + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + if err := brokerAdminCommand(config{}, []string{"protect", "add", "main", "--allow-owner-admin-override"}, &stdout); err != nil { + t.Fatal(err) + } + if err := brokerAdminCommand(config{}, []string{"protect", "list"}, &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"create", "--title", "demo"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"list"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"view", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"merge", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"close", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + want := "/protection/upsert,/protection/list,/prs/create,/prs/list,/prs/view,/prs/merge,/prs/close" + if strings.Join(*requests, ",") != want { + t.Fatalf("requests = %#v", *requests) + } + if !strings.Contains(stdout.String(), "created PR #7") || !strings.Contains(stdout.String(), "refs/heads/main") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestImportGitHubKeysConfirmsAndStoresSource(t *testing.T) { + var addReq brokerKeyRequest + target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + if r.URL.Path != "/keys/add" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&addReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer server.Close() + + oldTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "github.com" { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ssh-ed25519 AAAAGH octocat@github\n")), + Request: req, + }, nil + } + return http.DefaultTransport.RoundTrip(req) + }) + defer func() { http.DefaultClient.Transport = oldTransport }() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := brokerAdminKeysCommand(config{}, []string{"import-github", "octocat", "--role", "triage"}, strings.NewReader("y\n"), &stdout); err != nil { + t.Fatal(err) + } + if addReq.User != "octocat" || addReq.Role != "triage" || addReq.Source != "github:octocat" || len(addReq.PublicKeys) != 1 { + t.Fatalf("add req = %#v", addReq) + } +} + +func setupBrokerCommandTestRepo(t *testing.T, handler http.HandlerFunc) (string, *httptest.Server, *[]string) { + t.Helper() + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + requests := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + handler(w, r) + })) + for key, value := range map[string]string{ + "bucketgit.broker": server.URL, + "bucketgit.logicalRepo": "team/app.git", + "bucketgit.provider": "gcs", + "bucketgit.branch": "main", + } { + if _, err := runGit(target, "config", "--local", key, value); err != nil { + server.Close() + t.Fatal(err) + } + } + return target, server, &requests +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/broker_data_path.go b/broker_data_path.go new file mode 100644 index 0000000..3bfb357 --- /dev/null +++ b/broker_data_path.go @@ -0,0 +1,208 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type brokerObjectCapabilityRequest struct { + Repo brokerRepo `json:"repo"` + Path string `json:"path"` + Operation string `json:"operation"` + Size int64 `json:"size,omitempty"` + Resumable bool `json:"resumable,omitempty"` +} + +type brokerObjectCapabilityResponse struct { + Provider string `json:"provider"` + Mode string `json:"mode"` + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Bucket string `json:"bucket,omitempty"` + Prefix string `json:"prefix,omitempty"` + Object string `json:"object,omitempty"` + Region string `json:"region,omitempty"` + Credentials brokerObjectAWSCredentials `json:"credentials,omitempty"` +} + +type brokerObjectAWSCredentials struct { + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + SessionToken string `json:"session_token"` +} + +func (s *brokerGitStore) write(ctx context.Context, objectPath string, data []byte) error { + capability, err := s.objectCapability(ctx, objectPath, "write", int64(len(data))) + if err != nil { + return err + } + return s.writeWithCapability(ctx, capability, data) +} + +func (s *brokerGitStore) delete(ctx context.Context, objectPath string) error { + capability, err := s.objectCapability(ctx, objectPath, "delete", 0) + if err != nil { + return err + } + return s.deleteWithCapability(ctx, capability) +} + +func (s *brokerGitStore) readWithCapability(ctx context.Context, objectPath string) ([]byte, bool, error) { + capability, err := s.objectCapability(ctx, objectPath, "read", 0) + if err != nil { + if isBrokerNotFoundError(err) { + return nil, true, fs.ErrNotExist + } + if isBrokerCapabilityUnsupported(err) { + return nil, false, nil + } + return nil, true, err + } + data, err := s.getWithCapability(ctx, capability) + if errors.Is(err, fs.ErrNotExist) { + return nil, true, fs.ErrNotExist + } + return data, true, err +} + +func (s *brokerGitStore) objectCapability(ctx context.Context, objectPath, operation string, size int64) (brokerObjectCapabilityResponse, error) { + var resp brokerObjectCapabilityResponse + req := brokerObjectCapabilityRequest{ + Repo: repoForBroker(s.cfg), + Path: strings.TrimPrefix(objectPath, "/"), + Operation: operation, + Size: size, + Resumable: s.cfg.provider == "gcs" && operation == "write" && size > 32*1024*1024, + } + err := brokerPostContext(ctx, s.brokerURL, "/objects/capability", req, &resp) + return resp, err +} + +func (s *brokerGitStore) getWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse) ([]byte, error) { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + out, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + }) + if err != nil { + if isS3NotFound(err) { + return nil, fs.ErrNotExist + } + return nil, err + } + defer out.Body.Close() + return io.ReadAll(out.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, firstNonEmpty(capability.Method, http.MethodGet), capability.URL, nil) + if err != nil { + return nil, err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + if httpResp.StatusCode == http.StatusNotFound { + return nil, fs.ErrNotExist + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return nil, fmt.Errorf("broker object GET: %s %s", httpResp.Status, strings.TrimSpace(string(body))) + } + return io.ReadAll(httpResp.Body) +} + +func (s *brokerGitStore) writeWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse, data []byte) error { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + Body: bytes.NewReader(data), + }) + return err + } + method := firstNonEmpty(capability.Method, http.MethodPut) + httpReq, err := http.NewRequestWithContext(ctx, method, capability.URL, bytes.NewReader(data)) + if err != nil { + return err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("broker object %s: %s %s", method, httpResp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func (s *brokerGitStore) deleteWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse) error { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + }) + return err + } + httpReq, err := http.NewRequestWithContext(ctx, firstNonEmpty(capability.Method, http.MethodDelete), capability.URL, nil) + if err != nil { + return err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("broker object DELETE: %s %s", httpResp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func s3ClientForBrokerCapability(capability brokerObjectCapabilityResponse) *s3.Client { + region := firstNonEmpty(capability.Region, defaultAWSRegion()) + creds := credentials.NewStaticCredentialsProvider( + capability.Credentials.AccessKeyID, + capability.Credentials.SecretAccessKey, + capability.Credentials.SessionToken, + ) + return s3.New(s3.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(creds), + }) +} + +func isBrokerCapabilityUnsupported(err error) bool { + if err == nil { + return false + } + message := err.Error() + return strings.Contains(message, "unknown broker endpoint") || + strings.Contains(message, "404") +} diff --git a/broker_lifecycle.go b/broker_lifecycle.go new file mode 100644 index 0000000..71eaa7f --- /dev/null +++ b/broker_lifecycle.go @@ -0,0 +1,247 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" +) + +type brokerDeleteOptions struct { + provider string + profile string + region string + configPath string + deleteData bool + yes bool + firestoreDatabase string +} + +func brokerCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit broker delete [--provider gcp|aws] [--profile NAME] [--region REGION] [--data] --yes") + } + switch args[0] { + case "delete", "decommission": + return brokerDeleteCommand(ctx, base, args[1:], stdin, stdout) + default: + return fmt.Errorf("unknown broker command %q", args[0]) + } +} + +func brokerDeleteCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + opts, err := parseBrokerDeleteArgs(args) + if err != nil { + return err + } + if !opts.yes { + fmt.Fprint(stdout, "Delete bgit broker infrastructure? [y/N] ") + answer, _ := bufioReadLine(stdin) + if !strings.EqualFold(strings.TrimSpace(answer), "y") && !strings.EqualFold(strings.TrimSpace(answer), "yes") { + return errors.New("broker delete cancelled") + } + } + provider := firstNonEmpty(opts.provider, normalizeSetupProvider(firstNonEmpty(base.provider, "gcp"))) + if provider == "" { + return errors.New("broker delete requires --provider gcp|aws") + } + cfg := base + cfg.provider = mapSetupProviderToConfig(provider) + if opts.profile != "" { + cfg.gcloudConfiguration = opts.profile + cfg.gcloudConfigurationExplicit = true + } + switch provider { + case "gcs": + if err := deleteGCPBroker(ctx, cfg, opts, stdout); err != nil { + return err + } + case "s3": + if err := deleteAWSBroker(ctx, cfg, opts, stdout); err != nil { + return err + } + default: + return fmt.Errorf("unsupported broker provider %q", provider) + } + if err := removeDeletedBrokerFromGlobalConfig(opts, provider, cfg.gcloudConfiguration); err != nil { + return err + } + return nil +} + +func parseBrokerDeleteArgs(args []string) (brokerDeleteOptions, error) { + var opts brokerDeleteOptions + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--provider": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.provider = normalizeSetupProvider(value) + if opts.provider == "" { + return opts, fmt.Errorf("unsupported broker provider %q", value) + } + case "--profile": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.profile = value + case "--region": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.region = value + case "--config": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.configPath = expandHome(value) + case "--firestore-database": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.firestoreDatabase = value + case "--data": + opts.deleteData = true + case "--yes", "-y": + opts.yes = true + default: + return opts, fmt.Errorf("unsupported broker delete option %s", arg) + } + } + return opts, nil +} + +func deleteGCPBroker(ctx context.Context, cfg config, opts brokerDeleteOptions, stdout io.Writer) error { + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) + fmt.Fprintf(stdout, "deleting GCP bgit broker function in %s\n", region) + cmd := gcloudCommand(cfg.gcloudConfiguration, "functions", "delete", "bgit-broker", "--gen2", "--region", region, "--quiet") + if out, err := cmd.CombinedOutput(); err != nil { + if !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP bgit broker function: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "GCP function bgit-broker was already absent\n") + } + runDelete := gcloudCommand(cfg.gcloudConfiguration, "run", "services", "delete", "bgit-broker", "--region", region, "--quiet") + if out, err := runDelete.CombinedOutput(); err != nil && !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP bgit broker Cloud Run service: %w\n%s", err, strings.TrimSpace(string(out))) + } + if opts.deleteData { + database := firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") + fmt.Fprintf(stdout, "deleting GCP Firestore database %s\n", database) + deleteDB := gcloudCommand(cfg.gcloudConfiguration, "firestore", "databases", "delete", "--database="+database, "--quiet") + if out, err := deleteDB.CombinedOutput(); err != nil && !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP Firestore database %s: %w\n%s", database, err, strings.TrimSpace(string(out))) + } + } + fmt.Fprintf(stdout, "deleted GCP bgit broker\n") + return nil +} + +func deleteAWSBroker(ctx context.Context, cfg config, opts brokerDeleteOptions, stdout io.Writer) error { + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) + profile := strings.TrimSpace(firstNonEmpty(opts.profile, cfg.gcloudConfiguration)) + fmt.Fprintf(stdout, "deleting AWS CloudFormation stack bgit-broker in %s\n", region) + args := []string{"cloudformation", "delete-stack", "--stack-name", "bgit-broker", "--region", region} + if out, err := awsCommand(ctx, profile, args...).CombinedOutput(); err != nil { + if !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete AWS bgit broker stack: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "AWS stack bgit-broker was already absent\n") + return nil + } + waitArgs := []string{"cloudformation", "wait", "stack-delete-complete", "--stack-name", "bgit-broker", "--region", region} + if out, err := awsCommand(ctx, profile, waitArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("wait for AWS bgit broker stack deletion: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "deleted AWS bgit broker\n") + return nil +} + +func brokerDeleteMissing(out string, err error) bool { + message := strings.ToLower(out + "\n" + err.Error()) + return strings.Contains(message, "not found") || + strings.Contains(message, "not exist") || + strings.Contains(message, "does not exist") || + strings.Contains(message, "could not be found") || + strings.Contains(message, "resource not found") || + strings.Contains(message, "stack with id bgit-broker does not exist") +} + +func removeDeletedBrokerFromGlobalConfig(opts brokerDeleteOptions, provider, profile string) error { + path := opts.configPath + var err error + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return err + } + } + global, err := readGlobalConfig(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + switch provider { + case "gcs": + for i := range global.GCPProfiles { + if profile == "" || global.GCPProfiles[i].Name == profile { + global.GCPProfiles[i].Regions = clearGlobalProfileRegion(global.GCPProfiles[i].Regions, opts.region) + } + } + case "s3": + for i := range global.AWSProfiles { + if profile == "" || global.AWSProfiles[i].Name == profile { + global.AWSProfiles[i].Regions = clearGlobalProfileRegion(global.AWSProfiles[i].Regions, opts.region) + } + } + } + return writeGlobalConfig(path, global) +} + +func clearGlobalProfileRegion(regions []globalProfileRegion, region string) []globalProfileRegion { + region = strings.TrimSpace(region) + if region == "" { + return nil + } + var out []globalProfileRegion + for _, entry := range regions { + if entry.Name != region { + out = append(out, entry) + } + } + return out +} + +func mapSetupProviderToConfig(provider string) string { + switch provider { + case "gcs": + return "gcs" + case "s3": + return "s3" + default: + return provider + } +} + +func bufioReadLine(stdin io.Reader) (string, error) { + reader := bufio.NewReader(stdin) + return reader.ReadString('\n') +} diff --git a/global_config.go b/global_config.go new file mode 100644 index 0000000..af8e366 --- /dev/null +++ b/global_config.go @@ -0,0 +1,315 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const globalConfigVersion = 1 + +type globalConfig struct { + Version int + Identity globalIdentityConfig + GCPProfiles []globalGCPProfile + AWSProfiles []globalAWSProfile + Repos []globalRepoConfig +} + +type globalIdentityConfig struct { + Name string + Email string +} + +type globalGCPProfile struct { + Name string + ProjectID string + Account string + Region string + ServiceAccount string + BrokerURL string + BrokerVersion string + LastSetupAt string + Regions []globalProfileRegion +} + +type globalAWSProfile struct { + Name string + AccountID string + ARN string + Region string + BrokerURL string + BrokerVersion string + LastSetupAt string + Regions []globalProfileRegion +} + +type globalProfileRegion struct { + Name string + BrokerURL string + BrokerVersion string + LastSetupAt string +} + +type globalRepoConfig struct { + Name string + Profile string + BrokerURL string +} + +func defaultGlobalConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".bgit", "config.yaml"), nil +} + +func readGlobalConfig(path string) (globalConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return globalConfig{}, err + } + cfg, err := parseGlobalConfigYAML(data) + if err != nil { + return cfg, err + } + normalizeGlobalConfigProfileRegions(&cfg) + return cfg, nil +} + +type globalConfigYAML struct { + Version int `yaml:"version"` + Identity globalIdentityYAML `yaml:"identity,omitempty"` + GCP globalGCPConfigYAML `yaml:"gcp,omitempty"` + AWS globalAWSConfigYAML `yaml:"aws,omitempty"` + Repos map[string]globalRepoYAML `yaml:"repos,omitempty"` +} + +type globalIdentityYAML struct { + Name string `yaml:"name,omitempty"` + Email string `yaml:"email,omitempty"` +} + +type globalGCPConfigYAML struct { + Profiles map[string]globalGCPProfileYAML `yaml:"profiles,omitempty"` +} + +type globalAWSConfigYAML struct { + Profiles map[string]globalAWSProfileYAML `yaml:"profiles,omitempty"` +} + +type globalGCPProfileYAML struct { + ProjectID string `yaml:"project_id,omitempty"` + Account string `yaml:"account,omitempty"` + ServiceAccount string `yaml:"service_account,omitempty"` + Regions map[string]globalProfileRegionYAML `yaml:"regions,omitempty"` +} + +type globalAWSProfileYAML struct { + AccountID string `yaml:"account_id,omitempty"` + ARN string `yaml:"arn,omitempty"` + Regions map[string]globalProfileRegionYAML `yaml:"regions,omitempty"` +} + +type globalProfileRegionYAML struct { + BrokerURL string `yaml:"broker_url,omitempty"` + BrokerVersion string `yaml:"broker_version,omitempty"` + LastSetupAt string `yaml:"last_setup_at,omitempty"` +} + +type globalRepoYAML struct { + Profile string `yaml:"profile,omitempty"` + BrokerURL string `yaml:"broker_url,omitempty"` +} + +func parseGlobalConfigYAML(data []byte) (globalConfig, error) { + var raw globalConfigYAML + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&raw); err != nil { + return globalConfig{}, err + } + return globalConfigFromYAML(raw), nil +} + +func globalConfigFromYAML(raw globalConfigYAML) globalConfig { + cfg := globalConfig{ + Version: raw.Version, + Identity: globalIdentityConfig{ + Name: raw.Identity.Name, + Email: raw.Identity.Email, + }, + } + if cfg.Version == 0 { + cfg.Version = globalConfigVersion + } + for name, profile := range raw.GCP.Profiles { + next := globalGCPProfile{ + Name: name, + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: profile.ServiceAccount, + } + for regionName, region := range profile.Regions { + next.Regions = append(next.Regions, globalProfileRegion{ + Name: regionName, + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + }) + } + sortGlobalProfileRegions(next.Regions) + cfg.GCPProfiles = append(cfg.GCPProfiles, next) + } + for name, profile := range raw.AWS.Profiles { + next := globalAWSProfile{ + Name: name, + AccountID: profile.AccountID, + ARN: profile.ARN, + } + for regionName, region := range profile.Regions { + next.Regions = append(next.Regions, globalProfileRegion{ + Name: regionName, + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + }) + } + sortGlobalProfileRegions(next.Regions) + cfg.AWSProfiles = append(cfg.AWSProfiles, next) + } + for name, repo := range raw.Repos { + cfg.Repos = append(cfg.Repos, globalRepoConfig{Name: name, Profile: repo.Profile, BrokerURL: repo.BrokerURL}) + } + sortGlobalConfig(&cfg) + return cfg +} + +func globalConfigToYAML(cfg globalConfig) globalConfigYAML { + normalizeGlobalConfigProfileRegions(&cfg) + sortGlobalConfig(&cfg) + out := globalConfigYAML{ + Version: cfg.Version, + Identity: globalIdentityYAML{ + Name: cfg.Identity.Name, + Email: cfg.Identity.Email, + }, + GCP: globalGCPConfigYAML{Profiles: map[string]globalGCPProfileYAML{}}, + AWS: globalAWSConfigYAML{Profiles: map[string]globalAWSProfileYAML{}}, + Repos: map[string]globalRepoYAML{}, + } + if out.Version == 0 { + out.Version = globalConfigVersion + } + for _, profile := range cfg.GCPProfiles { + next := globalGCPProfileYAML{ + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: profile.ServiceAccount, + Regions: map[string]globalProfileRegionYAML{}, + } + for _, region := range profile.Regions { + next.Regions[region.Name] = globalProfileRegionYAML{ + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + } + } + out.GCP.Profiles[profile.Name] = next + } + for _, profile := range cfg.AWSProfiles { + next := globalAWSProfileYAML{ + AccountID: profile.AccountID, + ARN: profile.ARN, + Regions: map[string]globalProfileRegionYAML{}, + } + for _, region := range profile.Regions { + next.Regions[region.Name] = globalProfileRegionYAML{ + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + } + } + out.AWS.Profiles[profile.Name] = next + } + for _, repo := range cfg.Repos { + out.Repos[repo.Name] = globalRepoYAML{Profile: repo.Profile, BrokerURL: repo.BrokerURL} + } + return out +} + +func sortGlobalConfig(cfg *globalConfig) { + sort.Slice(cfg.GCPProfiles, func(i, j int) bool { + return cfg.GCPProfiles[i].Name < cfg.GCPProfiles[j].Name + }) + for i := range cfg.GCPProfiles { + sortGlobalProfileRegions(cfg.GCPProfiles[i].Regions) + } + sort.Slice(cfg.AWSProfiles, func(i, j int) bool { + return cfg.AWSProfiles[i].Name < cfg.AWSProfiles[j].Name + }) + for i := range cfg.AWSProfiles { + sortGlobalProfileRegions(cfg.AWSProfiles[i].Regions) + } + sort.Slice(cfg.Repos, func(i, j int) bool { + return cfg.Repos[i].Name < cfg.Repos[j].Name + }) +} + +func sortGlobalProfileRegions(regions []globalProfileRegion) { + sort.Slice(regions, func(i, j int) bool { + return regions[i].Name < regions[j].Name + }) +} + +func writeGlobalConfig(path string, cfg globalConfig) error { + if cfg.Version == 0 { + cfg.Version = globalConfigVersion + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := yaml.Marshal(globalConfigToYAML(cfg)) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func normalizeGlobalConfigProfileRegions(cfg *globalConfig) { + for i := range cfg.GCPProfiles { + profile := &cfg.GCPProfiles[i] + if len(profile.Regions) == 0 && strings.TrimSpace(profile.BrokerURL) != "" { + profile.Regions = append(profile.Regions, globalProfileRegion{ + Name: firstNonEmpty(profile.Region, "us-central1"), + BrokerURL: profile.BrokerURL, + BrokerVersion: profile.BrokerVersion, + LastSetupAt: profile.LastSetupAt, + }) + } + profile.Region = "" + profile.BrokerURL = "" + profile.BrokerVersion = "" + profile.LastSetupAt = "" + } + for i := range cfg.AWSProfiles { + profile := &cfg.AWSProfiles[i] + if len(profile.Regions) == 0 && strings.TrimSpace(profile.BrokerURL) != "" { + profile.Regions = append(profile.Regions, globalProfileRegion{ + Name: firstNonEmpty(profile.Region, "us-east-1"), + BrokerURL: profile.BrokerURL, + BrokerVersion: profile.BrokerVersion, + LastSetupAt: profile.LastSetupAt, + }) + } + profile.Region = "" + profile.BrokerURL = "" + profile.BrokerVersion = "" + profile.LastSetupAt = "" + } +} diff --git a/global_config_test.go b/global_config_test.go new file mode 100644 index 0000000..ef92a0e --- /dev/null +++ b/global_config_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestGlobalConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), ".bgit", "config.yaml") + want := globalConfig{ + Version: globalConfigVersion, + Identity: globalIdentityConfig{ + Name: "Dennis Example", + Email: "dennis@example.com", + }, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "example-test-123456", + Account: "dennis@example.com", + ServiceAccount: "bgit-broker@example-test-123456.iam.gserviceaccount.com", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://gcp.example.test", + BrokerVersion: brokerVersion, + LastSetupAt: "2026-05-16T10:00:00Z", + }}, + }}, + AWSProfiles: []globalAWSProfile{{ + Name: "work", + AccountID: "123456789012", + ARN: "arn:aws:iam::123456789012:user/dennis", + Regions: []globalProfileRegion{{ + Name: "us-east-1", + BrokerURL: "https://aws.example.test", + BrokerVersion: brokerVersion, + LastSetupAt: "2026-05-16T10:00:00Z", + }}, + }}, + Repos: []globalRepoConfig{{ + Name: "team/app.git", + Profile: "gcp:work", + BrokerURL: "https://gcp.example.test", + }}, + } + if err := writeGlobalConfig(path, want); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "gcp:") || + !strings.Contains(text, "identity:") || + !strings.Contains(text, "Dennis Example") || + !strings.Contains(text, "aws:") || + !strings.Contains(text, "profiles:") || + !strings.Contains(text, "work:") || + !strings.Contains(text, "regions:") || + !strings.Contains(text, "europe-west1:") || + !strings.Contains(text, "us-east-1:") { + t.Fatalf("config format =\n%s", string(data)) + } + got, err := readGlobalConfig(path) + if err != nil { + t.Fatal(err) + } + if got.Version != want.Version || + len(got.GCPProfiles) != 1 || + len(got.AWSProfiles) != 1 || + len(got.Repos) != 1 || + got.Identity != want.Identity { + t.Fatalf("cfg = %#v", got) + } + if !reflect.DeepEqual(got.GCPProfiles[0], want.GCPProfiles[0]) { + t.Fatalf("gcp profile = %#v", got.GCPProfiles[0]) + } + if !reflect.DeepEqual(got.AWSProfiles[0], want.AWSProfiles[0]) { + t.Fatalf("aws profile = %#v", got.AWSProfiles[0]) + } + if got.Repos[0] != want.Repos[0] { + t.Fatalf("repo = %#v", got.Repos[0]) + } +} + +func TestDefaultGlobalConfigPathUsesYAML(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + got, err := defaultGlobalConfigPath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".bgit", "config.yaml") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } +} + +func TestReadGlobalConfigDoesNotFallBackToLegacyConfig(t *testing.T) { + dir := filepath.Join(t.TempDir(), ".bgit") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(dir, "config") + data := `version = 1 + +[[gcp.profiles]] +name = "work" +region = "europe-west1" +broker_url = "https://gcp.example.test" +` + if err := os.WriteFile(legacyPath, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + _, err := readGlobalConfig(filepath.Join(dir, "config.yaml")) + if err == nil { + t.Fatal("expected missing config.yaml error") + } +} + +func TestGlobalConfigRejectsUnknownKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := "version: 1\ngcp:\n unknown: value\n" + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + _, err := readGlobalConfig(path) + if err == nil || !strings.Contains(err.Error(), "field unknown not found") { + t.Fatalf("err = %v", err) + } +} diff --git a/go.mod b/go.mod index b7b68ff..6e3ebc7 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ require ( cloud.google.com/go/iam v1.1.12 cloud.google.com/go/storage v1.43.0 golang.org/x/oauth2 v0.22.0 + golang.org/x/term v0.22.0 google.golang.org/api v0.191.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index 9eafada..895c65f 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..68d0ccb --- /dev/null +++ b/identity.go @@ -0,0 +1,167 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +const defaultBucketGitIdentityName = "BucketGit Client" + +var identityEmailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +type identityConfig struct { + Name string + Email string + UsesDefault bool +} + +func defaultBucketGitIdentityEmail() string { + username := firstNonEmpty(os.Getenv("USER"), os.Getenv("USERNAME"), "username") + username = strings.ToLower(strings.TrimSpace(username)) + var clean strings.Builder + for _, r := range username { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + clean.WriteRune(r) + } + } + if clean.Len() == 0 { + return "username@bucketgit.com" + } + return clean.String() + "@bucketgit.com" +} + +func readGlobalIdentity() globalIdentityConfig { + path, err := defaultGlobalConfigPath() + if err != nil { + return globalIdentityConfig{} + } + cfg, err := readGlobalConfig(path) + if err != nil { + return globalIdentityConfig{} + } + return cfg.Identity +} + +func effectiveRepositoryIdentity(repo *localRepository) identityConfig { + name := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), repo.configValue("user.name")) + email := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), repo.configValue("user.email")) + if name == "" || email == "" { + global := readGlobalIdentity() + name = firstNonEmpty(name, global.Name) + email = firstNonEmpty(email, global.Email) + } + defaultEmail := defaultBucketGitIdentityEmail() + name = firstNonEmpty(name, defaultBucketGitIdentityName) + email = firstNonEmpty(email, defaultEmail) + return identityConfig{ + Name: name, + Email: email, + UsesDefault: name == defaultBucketGitIdentityName || email == defaultEmail, + } +} + +func (r *localRepository) identityValue(key string) string { + if value := r.configValue(key); value != "" { + return value + } + global := readGlobalIdentity() + switch key { + case "user.name": + return global.Name + case "user.email": + return global.Email + default: + return "" + } +} + +func maybeConfigureIdentityBeforePush(stdin io.Reader, stdout io.Writer) error { + repo, err := openLocalRepository(".") + if err != nil { + return nil + } + identity := effectiveRepositoryIdentity(repo) + if !identity.UsesDefault { + return nil + } + fmt.Fprintf(stdout, "BucketGit is using the default identity %s <%s>.\n", identity.Name, identity.Email) + fmt.Fprintln(stdout, "You have not configured your name and email address yet.") + fmt.Fprintf(stdout, "Configure a global BucketGit identity now? [Y/n] ") + reader := bufio.NewReader(stdin) + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + answer = strings.ToLower(strings.TrimSpace(answer)) + if errors.Is(err, io.EOF) && answer == "" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + if answer == "n" || answer == "no" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + name, email, err := readIdentityFields(reader, stdout, "", "") + if err != nil { + return err + } + if name == "" || email == "" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + return writeGlobalIdentity(name, email) +} + +func readIdentityFields(reader *bufio.Reader, stdout io.Writer, currentName, currentEmail string) (string, string, error) { + fmt.Fprintf(stdout, "Name") + if currentName != "" { + fmt.Fprintf(stdout, " [%s]", currentName) + } + fmt.Fprint(stdout, ": ") + name, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + name = strings.TrimSpace(name) + if name == "" { + name = strings.TrimSpace(currentName) + } + fmt.Fprintf(stdout, "Email") + if currentEmail != "" { + fmt.Fprintf(stdout, " [%s]", currentEmail) + } + fmt.Fprint(stdout, ": ") + email, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + email = strings.TrimSpace(email) + if email == "" { + email = strings.TrimSpace(currentEmail) + } + if email != "" && !identityEmailPattern.MatchString(email) { + return "", "", fmt.Errorf("email address %q looks invalid", email) + } + return name, email, nil +} + +func writeGlobalIdentity(name, email string) error { + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + cfg, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + cfg = globalConfig{Version: globalConfigVersion} + } + cfg.Identity = globalIdentityConfig{Name: strings.TrimSpace(name), Email: strings.TrimSpace(email)} + return writeGlobalConfig(path, cfg) +} diff --git a/local_config.go b/local_config.go index 8745d7c..a31917f 100644 --- a/local_config.go +++ b/local_config.go @@ -99,6 +99,91 @@ func parseConfigArgs(args []string) (configOptions, error) { return opts, nil } +func configArgsAreGlobal(args []string) bool { + for _, arg := range args { + if arg == "--global" { + return true + } + } + return false +} + +func globalConfigCommand(args []string, stdout io.Writer) error { + opts, err := parseGlobalConfigArgs(args) + if err != nil { + return err + } + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + cfg, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + cfg = globalConfig{Version: globalConfigVersion} + } + if opts.list { + if cfg.Identity.Name != "" { + fmt.Fprintf(stdout, "user.name=%s\n", cfg.Identity.Name) + } + if cfg.Identity.Email != "" { + fmt.Fprintf(stdout, "user.email=%s\n", cfg.Identity.Email) + } + return nil + } + if opts.unset { + switch opts.key { + case "user.name": + cfg.Identity.Name = "" + case "user.email": + cfg.Identity.Email = "" + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return writeGlobalConfig(path, cfg) + } + if opts.value == nil { + switch opts.key { + case "user.name": + if cfg.Identity.Name != "" { + fmt.Fprintln(stdout, cfg.Identity.Name) + } + case "user.email": + if cfg.Identity.Email != "" { + fmt.Fprintln(stdout, cfg.Identity.Email) + } + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return nil + } + switch opts.key { + case "user.name": + cfg.Identity.Name = *opts.value + case "user.email": + if !identityEmailPattern.MatchString(*opts.value) { + return fmt.Errorf("email address %q looks invalid", *opts.value) + } + cfg.Identity.Email = *opts.value + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return writeGlobalConfig(path, cfg) +} + +func parseGlobalConfigArgs(args []string) (configOptions, error) { + var filtered []string + for _, arg := range args { + if arg == "--global" { + continue + } + filtered = append(filtered, arg) + } + return parseConfigArgs(filtered) +} + func readLocalConfigFile(path string) (localConfigFile, error) { cfg := localConfigFile{sections: map[string]map[string]string{}} data, err := os.ReadFile(path) diff --git a/local_extra.go b/local_extra.go index 74c287f..bf062fb 100644 --- a/local_extra.go +++ b/local_extra.go @@ -37,6 +37,7 @@ type renameChange struct { func (r *localRepository) diff(args []string, stdout io.Writer) error { cached := false + var revisions []string for _, arg := range args { switch arg { case "--cached", "--staged": @@ -45,14 +46,23 @@ func (r *localRepository) diff(args []string, stdout io.Writer) error { if strings.HasPrefix(arg, "-") { return fmt.Errorf("unsupported diff option %s", arg) } + revisions = append(revisions, arg) } } + if len(revisions) > 2 { + return errors.New("diff accepts at most two revisions") + } idx, err := r.readIndex() if err != nil { return err } var left map[string]string - if cached { + if len(revisions) > 0 { + left, err = r.revisionTreeFiles(revisions[0]) + if err != nil { + return err + } + } else if cached { left, err = r.headTreeFiles() if err != nil { return err @@ -64,25 +74,53 @@ func (r *localRepository) diff(args []string, stdout io.Writer) error { } } right := map[string]string{} - if cached { + if len(revisions) == 2 { + right, err = r.revisionTreeFiles(revisions[1]) + if err != nil { + return err + } + } else if cached { for _, entry := range idx.entries { right[entry.path] = entry.hash } } else { + paths := map[string]struct{}{} + for path := range left { + paths[path] = struct{}{} + } for _, entry := range idx.entries { - hash, err := r.writeBlobFromWorktree(entry.path) + paths[entry.path] = struct{}{} + } + for path := range paths { + hash, err := r.writeBlobFromWorktree(path) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return err } - right[entry.path] = hash + right[path] = hash } } return r.printDiff(left, right, stdout) } +func (r *localRepository) revisionTreeFiles(revision string) (map[string]string, error) { + hash, err := r.resolveRevision(revision) + if err != nil { + return nil, fmt.Errorf("unknown revision %q", revision) + } + commit, err := r.commitObject(hash) + if err != nil { + return nil, err + } + files := map[string]string{} + if err := r.collectTreeFiles(commit.tree, "", files); err != nil { + return nil, err + } + return files, nil +} + func (r *localRepository) headTreeFiles() (map[string]string, error) { files := map[string]string{} if head, err := r.resolveRevision("HEAD"); err == nil { @@ -1178,6 +1216,131 @@ func simpleLineDiff(left, right string) []string { if left == right { return nil } + if len(a)*len(b) > 900000 { + return simpleWholeFileDiff(a, b) + } + ops := simpleLineDiffOps(a, b) + hunks := simpleLineDiffHunks(ops, 3) + var out []string + for _, hunk := range hunks { + oldStart, oldCount, newStart, newCount := simpleDiffHunkRange(ops[hunk.start:hunk.end]) + out = append(out, fmt.Sprintf("@@ %s %s @@", hunkRangeFrom(oldStart, oldCount, "-"), hunkRangeFrom(newStart, newCount, "+"))) + for _, op := range ops[hunk.start:hunk.end] { + out = append(out, string(op.kind)+op.text) + } + } + return out +} + +type simpleDiffOp struct { + kind byte + text string + oldLine int + newLine int +} + +type simpleDiffHunk struct { + start int + end int +} + +func simpleLineDiffOps(a, b []string) []simpleDiffOp { + rows := make([][]int, len(a)+1) + for i := range rows { + rows[i] = make([]int, len(b)+1) + } + for i := len(a) - 1; i >= 0; i-- { + for j := len(b) - 1; j >= 0; j-- { + if a[i] == b[j] { + rows[i][j] = rows[i+1][j+1] + 1 + } else if rows[i+1][j] >= rows[i][j+1] { + rows[i][j] = rows[i+1][j] + } else { + rows[i][j] = rows[i][j+1] + } + } + } + i, j := 0, 0 + var ops []simpleDiffOp + for i < len(a) || j < len(b) { + switch { + case i < len(a) && j < len(b) && a[i] == b[j]: + ops = append(ops, simpleDiffOp{kind: ' ', text: a[i], oldLine: i + 1, newLine: j + 1}) + i++ + j++ + case i < len(a) && (j == len(b) || rows[i+1][j] >= rows[i][j+1]): + ops = append(ops, simpleDiffOp{kind: '-', text: a[i], oldLine: i + 1, newLine: j + 1}) + i++ + case j < len(b): + ops = append(ops, simpleDiffOp{kind: '+', text: b[j], oldLine: i + 1, newLine: j + 1}) + j++ + } + } + return ops +} + +func simpleLineDiffHunks(ops []simpleDiffOp, context int) []simpleDiffHunk { + var hunks []simpleDiffHunk + for i, op := range ops { + if op.kind == ' ' { + continue + } + start := i - context + if start < 0 { + start = 0 + } + end := i + context + 1 + if end > len(ops) { + end = len(ops) + } + if len(hunks) > 0 && start <= hunks[len(hunks)-1].end { + if end > hunks[len(hunks)-1].end { + hunks[len(hunks)-1].end = end + } + continue + } + hunks = append(hunks, simpleDiffHunk{start: start, end: end}) + } + return hunks +} + +func simpleDiffHunkRange(ops []simpleDiffOp) (int, int, int, int) { + oldStart, newStart := 0, 0 + oldCount, newCount := 0, 0 + for _, op := range ops { + if op.kind != '+' { + if oldStart == 0 { + oldStart = op.oldLine + } + oldCount++ + } + if op.kind != '-' { + if newStart == 0 { + newStart = op.newLine + } + newCount++ + } + } + if oldStart == 0 && len(ops) > 0 { + oldStart = ops[0].oldLine - 1 + } + if newStart == 0 && len(ops) > 0 { + newStart = ops[0].newLine - 1 + } + return oldStart, oldCount, newStart, newCount +} + +func hunkRangeFrom(start, count int, prefix string) string { + if count == 0 { + return prefix + strconv.Itoa(start) + ",0" + } + if count == 1 { + return prefix + strconv.Itoa(start) + } + return prefix + strconv.Itoa(start) + "," + strconv.Itoa(count) +} + +func simpleWholeFileDiff(a, b []string) []string { var out []string out = append(out, fmt.Sprintf("@@ %s %s @@", hunkRange("-", len(a)), hunkRange("+", len(b)))) for _, line := range a { @@ -1264,8 +1427,8 @@ func (r *localRepository) findPathInTree(treeHash, path string) (string, error) } func (r *localRepository) commitWithParents(treeHash string, parents []string, message string) (string, error) { - authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.configValue("user.name"), "bgit") - authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.configValue("user.email"), "bgit@example.com") + authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.identityValue("user.name"), defaultBucketGitIdentityName) + authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.identityValue("user.email"), defaultBucketGitIdentityEmail()) committerName := firstNonEmpty(os.Getenv("GIT_COMMITTER_NAME"), authorName) committerEmail := firstNonEmpty(os.Getenv("GIT_COMMITTER_EMAIL"), authorEmail) now := time.Now() diff --git a/local_native.go b/local_native.go index 8263fbf..b40bed0 100644 --- a/local_native.go +++ b/local_native.go @@ -555,8 +555,8 @@ func (r *localRepository) commit(args []string, stdout io.Writer) error { } } after := idx.treeFiles() - authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.configValue("user.name"), "bgit") - authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.configValue("user.email"), "bgit@example.com") + authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.identityValue("user.name"), defaultBucketGitIdentityName) + authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.identityValue("user.email"), defaultBucketGitIdentityEmail()) committerName := firstNonEmpty(os.Getenv("GIT_COMMITTER_NAME"), authorName) committerEmail := firstNonEmpty(os.Getenv("GIT_COMMITTER_EMAIL"), authorEmail) now := time.Now() @@ -620,30 +620,42 @@ func (r *localRepository) checkout(cmd string, args []string, stdout io.Writer) if err := r.writeRef(branchRef(opts.target), hash); err != nil { return err } + if err := setGitBranchTrackingIfOrigin(r.worktree, opts.target); err != nil { + return err + } + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := r.setHEADSymbolic(branchRef(opts.target)); err != nil { return err } fmt.Fprintf(stdout, "Switched to a new branch '%s'\n", opts.target) - return r.checkoutCommit(hash) + return nil } if hash, err := r.resolveRevision(branchRef(opts.target)); err == nil { + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := r.setHEADSymbolic(branchRef(opts.target)); err != nil { return err } fmt.Fprintf(stdout, "Switched to branch '%s'\n", opts.target) - return r.checkoutCommit(hash) + return nil } hash, err := r.resolveRevision(opts.target) if err != nil { return err } + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := os.WriteFile(filepath.Join(r.gitDir, "HEAD"), []byte(hash+"\n"), 0o644); err != nil { return err } if commit, err := r.commitObject(hash); err == nil { fmt.Fprintf(stdout, "HEAD is now at %s %s\n", hash[:7], commit.subject) } - return r.checkoutCommit(hash) + return nil } func (r *localRepository) pathExistsInHead(path string) bool { @@ -714,7 +726,7 @@ func (r *localRepository) checkoutPaths(source string, paths []string) error { } func (r *localRepository) branch(args []string, stdout io.Writer) error { - if len(args) == 0 { + if len(args) == 0 || (len(args) == 1 && args[0] == "-a") { branches, err := r.listRefs("refs/heads") if err != nil { return err @@ -728,6 +740,15 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { } fmt.Fprintln(stdout, prefix+short) } + if len(args) == 1 { + remotes, err := r.listRefs("refs/remotes") + if err != nil { + return err + } + for _, name := range remotes { + fmt.Fprintln(stdout, " "+strings.TrimPrefix(name, "refs/")) + } + } return nil } if args[0] == "-d" || args[0] == "-D" { @@ -739,6 +760,9 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { if err := r.deleteRef(ref); err != nil { return err } + if err := unsetGitBranchTracking(r.worktree, args[1]); err != nil { + return err + } short := hash if len(short) > 7 { short = short[:7] @@ -757,7 +781,10 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { if err != nil { return err } - return r.writeRef(branchRef(args[0]), hash) + if err := r.writeRef(branchRef(args[0]), hash); err != nil { + return err + } + return setGitBranchTrackingIfOrigin(r.worktree, args[0]) } func (r *localRepository) tag(args []string, stdout io.Writer) error { @@ -814,8 +841,8 @@ func (r *localRepository) tag(args []string, stdout io.Writer) error { return errors.New("annotated tags require -m") } now := time.Now() - name := firstNonEmpty(r.configValue("user.name"), "bgit") - email := firstNonEmpty(r.configValue("user.email"), "bgit@example.com") + name := firstNonEmpty(r.identityValue("user.name"), defaultBucketGitIdentityName) + email := firstNonEmpty(r.identityValue("user.email"), defaultBucketGitIdentityEmail()) var buf bytes.Buffer fmt.Fprintf(&buf, "object %s\n", hash) fmt.Fprintf(&buf, "type commit\n") @@ -1150,40 +1177,76 @@ func (r *localRepository) checkoutCommit(hash string) error { if err != nil { return err } - files := map[string]string{} - if err := r.collectTreeFiles(commit.tree, "", files); err != nil { + files := map[string]treeFile{} + if err := r.collectTreeFileEntries(commit.tree, "", files); err != nil { return err } current, err := r.readIndex() if err != nil { return err } + dirty, err := r.dirtyTrackedFiles(current) + if err != nil { + return err + } + var conflicts []string for _, entry := range current.entries { - if _, ok := files[entry.path]; !ok { + target, ok := files[entry.path] + _, isDirty := dirty[entry.path] + if isDirty && (!ok || target.hash != entry.hash) { + conflicts = append(conflicts, entry.path) + continue + } + if !ok && !isDirty { err := os.Remove(filepath.Join(r.worktree, filepath.FromSlash(entry.path))) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } } } + if len(conflicts) > 0 { + sort.Strings(conflicts) + return checkoutLocalChangesError(conflicts) + } idx := gitIndex{} - for path, blobHash := range files { - obj, err := r.storeObject(blobHash) - if err != nil { - return err + for path, meta := range files { + if _, isDirty := dirty[path]; !isDirty { + if err := r.writeBlobToWorktree(meta.hash, path); err != nil { + return err + } } - target := filepath.Join(r.worktree, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err + idx.entries = append(idx.entries, indexEntry{path: path, hash: meta.hash, mode: meta.mode}) + } + idx.sort() + return r.writeIndex(idx) +} + +func (r *localRepository) dirtyTrackedFiles(idx gitIndex) (map[string]string, error) { + dirty := map[string]string{} + for _, entry := range idx.entries { + hash, err := r.writeBlobFromWorktree(entry.path) + if errors.Is(err, fs.ErrNotExist) { + dirty[entry.path] = "" + continue } - if err := os.WriteFile(target, obj.data, 0o644); err != nil { - return err + if err != nil { + return nil, err } - if err := r.addPathToIndex(&idx, path); err != nil { - return err + if hash != entry.hash { + dirty[entry.path] = hash } } - return r.writeIndex(idx) + return dirty, nil +} + +func checkoutLocalChangesError(paths []string) error { + var b strings.Builder + b.WriteString("Your local changes to the following files would be overwritten by checkout:\n") + for _, path := range paths { + fmt.Fprintf(&b, "\t%s\n", path) + } + b.WriteString("Please commit your changes or stash them before you switch branches.") + return errors.New(b.String()) } func (r *localRepository) commitObject(hash string) (commitObject, error) { diff --git a/main.go b/main.go index 2991687..614b005 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( const defaultBranch = "main" const defaultAuthMode = "gcloud" +const brokerVersion = "1.0.0-dev" var version = "dev" @@ -30,8 +31,12 @@ type config struct { prefix string branch string origin string + brokerURL string + logicalRepo string + region string auth string gcloudConfiguration string + direct bool authExplicit bool gcloudConfigurationExplicit bool versionRequested bool @@ -77,34 +82,54 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if isUnsupportedCommand(cmd) && !(cmd == "show" && explicitBucket) { return unsupportedCommand(cmd) } - if cmd == "origin" { - return originCommand(cmdArgs, stdout) + if cmd == "origin" || cmd == "remote" { + return fmt.Errorf("bgit %s is direct bucket configuration; use bgit direct %s", cmd, strings.Join(append([]string{cmd}, cmdArgs...), " ")) } - if cmd == "remote" { - return remoteCommand(cmdArgs, stdout) + if cmd == "direct" { + cfg.direct = true + return directCommand(context.Background(), cfg, cmdArgs, stdin, stdout) } if cmd == "admin" { - return adminCommand(cfg, cmdArgs, stdout) + return brokerAdminCommandWithInput(cfg, cmdArgs, stdin, stdout) } if cmd == "ssh" { return sshCommand(cfg, cmdArgs, stdout, stderr) } + if cmd == "import-gh-user" || cmd == "create-gcloud-profile" || cmd == "create-aws-profile" { + return fmt.Errorf("unknown command %q", cmd) + } + if cmd == "setup" { + return setupCommand(context.Background(), cfg, cmdArgs, stdin, stdout) + } + if cmd == "broker" { + return brokerCommand(context.Background(), cfg, cmdArgs, stdin, stdout) + } + if cmd == "pr" { + return prCommand(cmdArgs, stdin, stdout) + } if cmd == "web" { return webCommand(context.Background(), cfg, cmdArgs, stdout) } - if cmd == "create-gcloud-profile" { - return createGcloudProfileCommand(cmdArgs, stdin, stdout) + if cmd == "config" && configArgsAreGlobal(cmdArgs) { + return globalConfigCommand(cmdArgs, stdout) } if isLocalGitCommand(cmd) || (!explicitBucket && isPreferLocalGitCommand(cmd)) { return nativeLocalCommand(cmd, cmdArgs, stdout) } + if cfg.authExplicit { + return errors.New("--auth is only supported with bgit direct") + } + if explicitBucket { + return errors.New("direct bucket operations require bgit direct; run bgit direct help") + } - ctx := context.Background() if cmd == "clone" { - return cloneCommand(ctx, cfg, cmdArgs, stdout) + cmdArgs = mergeBrokerSelectionArgs(cmdArgs, cfg) + return brokerCloneCommand(cmdArgs, stdin, stdout) } - if cmd == "init" && cfg.bucket == "" { - return initEmptyWorktree(cmdArgs, stdout) + if cmd == "init" { + cmdArgs = mergeBrokerSelectionArgs(cmdArgs, cfg) + return brokerInitCommand(cmdArgs, stdin, stdout) } if cfg.bucket == "" { localCfg, err := readLocalConfig(".") @@ -112,13 +137,19 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { cfg = mergeConfig(cfg, localCfg) } } - if cfg.bucket == "" { + if !cfg.direct && cfg.gcloudConfigurationExplicit && isNativeRemoteCommand(cmd) { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + if cfg.bucket == "" && cfg.brokerURL == "" { if cmd == "push" { return missingOriginError() } return errors.New("--bucket is required outside a bucketgit checkout") } + ctx := context.Background() store, closeStore, err := newRemoteStore(ctx, cfg, isReadOnlyRemoteCommand(cmd)) if err != nil { return fmt.Errorf("create remote store: %w", err) @@ -126,7 +157,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { defer closeStore() if isNativeRemoteCommand(cmd) { - if commandCreatesBucket(cmd) || cmd == "push" { + if cfg.brokerURL == "" && (commandCreatesBucket(cmd) || cmd == "push") { if err := ensureBucket(ctx, cfg); err != nil { return err } @@ -140,6 +171,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { case "pull": return repo.pull(ctx, cmdArgs, stdout) case "push": + if err := maybeConfigureIdentityBeforePush(stdin, stdout); err != nil { + return err + } return repo.push(ctx, cmdArgs, stdout) case "ls-remote": return repo.lsRemote(ctx, cmdArgs, stdout) @@ -157,6 +191,136 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return fmt.Errorf("unknown command %q", cmd) } +func applyExplicitBrokerProfileSelection(cfg *config, cmd string) error { + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + global, err := readGlobalConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 { + return nil + } + profile, err := selectBrokerProfileForCommand(profiles, cfg.gcloudConfiguration, cfg.region, "bgit "+cmd) + if err != nil { + return err + } + cfg.provider = profile.Provider + cfg.brokerURL = profile.BrokerURL + cfg.region = profile.Region + cfg.gcloudConfiguration = profile.QualifiedName + if cfg.logicalRepo == "" && cfg.prefix != "" { + cfg.logicalRepo = strings.Trim(cfg.prefix, "/") + } + return nil +} + +func mergeBrokerProfileArg(args []string, cfg config) []string { + return mergeBrokerSelectionArgs(args, cfg) +} + +func mergeBrokerSelectionArgs(args []string, cfg config) []string { + merged := append([]string{}, args...) + for i := 0; i < len(args); i++ { + arg := args[i] + name, _, _ := strings.Cut(arg, "=") + switch name { + case "--profile": + cfg.gcloudConfigurationExplicit = false + case "--region": + cfg.region = "" + } + } + if cfg.gcloudConfigurationExplicit && strings.TrimSpace(cfg.gcloudConfiguration) != "" { + merged = append(merged, "--profile", cfg.gcloudConfiguration) + } + if strings.TrimSpace(cfg.region) != "" { + merged = append(merged, "--region", cfg.region) + } + return merged +} + +func directCommand(ctx context.Context, cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + cfg.direct = true + if len(args) == 0 { + return errors.New("usage: bgit direct clone|init|fetch|pull|push|ls-remote|ls|cat|show|log|put|admin [args]") + } + cmd := args[0] + cmdArgs := args[1:] + if cmd == "help" || cmd == "-h" || cmd == "--help" { + return commandHelp("direct", stdout) + } + if cmd == "admin" { + return adminCommand(cfg, cmdArgs, stdout) + } + if cmd == "origin" { + return originCommand(cmdArgs, stdout) + } + if cmd == "remote" { + return remoteCommand(cmdArgs, stdout) + } + if cmd == "clone" { + return cloneCommand(ctx, cfg, cmdArgs, stdout) + } + if cmd == "init" && cfg.bucket == "" { + return initEmptyWorktree(cmdArgs, stdout) + } + if cfg.bucket == "" { + localCfg, err := readLocalConfig(".") + if err == nil { + cfg = mergeConfig(cfg, localCfg) + } + } + if cfg.bucket == "" { + if cmd == "push" { + return missingOriginError() + } + return errors.New("--bucket is required outside a bucketgit checkout") + } + store, closeStore, err := newRemoteStore(ctx, cfg, isReadOnlyRemoteCommand(cmd)) + if err != nil { + return fmt.Errorf("create remote store: %w", err) + } + defer closeStore() + if !isNativeRemoteCommand(cmd) { + return fmt.Errorf("unknown direct command %q", cmd) + } + if commandCreatesBucket(cmd) || cmd == "push" { + if err := ensureBucket(ctx, cfg); err != nil { + return err + } + } + repo := openNativeGitRepo(store, cfg) + switch cmd { + case "init": + return repo.initWorktree(ctx, cmdArgs, stdout) + case "fetch": + return repo.fetch(ctx, cmdArgs, stdout) + case "pull": + return repo.pull(ctx, cmdArgs, stdout) + case "push": + return repo.push(ctx, cmdArgs, stdout) + case "ls-remote": + return repo.lsRemote(ctx, cmdArgs, stdout) + case "ls", "list": + return repo.listFiles(ctx, cmdArgs, stdout) + case "cat", "show": + return repo.catFile(ctx, cmdArgs, stdout) + case "log": + return repo.log(ctx, cmdArgs, stdout) + case "put": + return repo.putFile(ctx, cmdArgs, stdin, stdout) + default: + return fmt.Errorf("unknown direct command %q", cmd) + } +} + func isNativeRemoteCommand(cmd string) bool { switch cmd { case "init", "fetch", "pull", "push", "ls-remote", "ls", "list", "cat", "show", "log", "put": @@ -222,6 +386,15 @@ func extractGlobalFlags(args []string, cfg *config) ([]string, error) { value = args[i] } cfg.branch = value + case "--region": + if !hasValue { + i++ + if i >= len(args) { + return nil, errors.New("--region requires a value") + } + value = args[i] + } + cfg.region = value case "--auth": if !hasValue { i++ @@ -276,6 +449,12 @@ func mergeConfig(primary, fallback config) config { if primary.prefix == "" { primary.prefix = fallback.prefix } + if primary.brokerURL == "" { + primary.brokerURL = fallback.brokerURL + } + if primary.logicalRepo == "" { + primary.logicalRepo = fallback.logicalRepo + } if primary.branch == "" || primary.branch == defaultBranch { primary.branch = fallback.branch } @@ -337,6 +516,31 @@ func readLocalConfig(dir string) (config, error) { branch = defaultBranch } localAuth := defaultBranchAuth(dir) + brokerURL := "" + if brokerOut, brokerErr := runGit(dir, "config", "--get", "bucketgit.broker"); brokerErr == nil { + brokerURL = strings.TrimSpace(string(brokerOut)) + } + logicalRepo := "" + if logicalOut, logicalErr := runGit(dir, "config", "--get", "bucketgit.logicalRepo"); logicalErr == nil { + logicalRepo = strings.Trim(strings.TrimSpace(string(logicalOut)), "/") + } + localProvider := "" + if providerOut, providerErr := runGit(dir, "config", "--get", "bucketgit.provider"); providerErr == nil { + localProvider = strings.TrimSpace(string(providerOut)) + } + if brokerURL != "" && logicalRepo != "" { + provider := firstNonEmpty(localProvider, "gcs") + return config{ + provider: provider, + prefix: logicalRepo, + branch: branch, + origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo), + brokerURL: brokerURL, + logicalRepo: logicalRepo, + auth: localAuth.auth, + gcloudConfiguration: localAuth.gcloudConfiguration, + }, nil + } originOut, originErr := runGit(dir, "config", "--get", "bucketgit.origin") if originErr == nil { @@ -371,18 +575,15 @@ func readLocalConfig(dir string) (config, error) { } bucket := strings.TrimSpace(string(bucketOut)) prefix := strings.Trim(strings.TrimSpace(string(prefixOut)), "/") - provider := "gcs" - if providerOut, err := runGit(dir, "config", "--get", "bucketgit.provider"); err == nil { - if value := strings.TrimSpace(string(providerOut)); value != "" { - provider = value - } - } + provider := firstNonEmpty(localProvider, "gcs") return config{ provider: provider, bucket: bucket, prefix: prefix, branch: branch, origin: originForConfig(config{provider: provider, bucket: bucket, prefix: prefix}), + brokerURL: brokerURL, + logicalRepo: logicalRepo, auth: localAuth.auth, gcloudConfiguration: localAuth.gcloudConfiguration, }, nil @@ -411,16 +612,16 @@ func missingOriginError() error { return errors.New(`No configured push destination. Either specify the repository from the command-line: - bgit --bucket bucket-name --prefix path/to/repo.git push + bgit --bucket bucket-name --prefix path/to/repo.git direct push -or configure a bgit origin: +or configure a direct bgit origin: - bgit origin gs://bucket-name/path/to/repo.git - bgit origin s3://bucket-name/path/to/repo.git + bgit direct origin gs://bucket-name/path/to/repo.git + bgit direct origin s3://bucket-name/path/to/repo.git and then push: - bgit push`) + bgit direct push`) } func newStorageClient(ctx context.Context, cfg config) (*storage.Client, error) { @@ -455,6 +656,21 @@ func isReadOnlyRemoteCommand(cmd string) bool { } func newRemoteStore(ctx context.Context, cfg config, publicFallback bool) (gitRemoteStore, func(), error) { + if !cfg.direct { + if cfg.brokerURL == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { + cfg.brokerURL = strings.TrimSpace(string(out)) + } + } + if cfg.logicalRepo == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.logicalRepo"); err == nil { + cfg.logicalRepo = strings.Trim(strings.TrimSpace(string(out)), "/") + } + } + if cfg.brokerURL != "" { + return &brokerGitStore{brokerURL: cfg.brokerURL, cfg: cfg}, func() {}, nil + } + } provider := cfg.provider if provider == "" { provider = "gcs" @@ -549,32 +765,52 @@ func gcloudCommand(configuration string, args ...string) *exec.Cmd { func usage(w io.Writer) error { _, err := fmt.Fprint(w, `usage: bgit [args] -common commands: - clone gs://bucket/prefix.git [directory] - clone s3://bucket/prefix.git [directory] - init [directory] - origin gs://bucket/prefix.git - origin s3://bucket/prefix.git - ssh setup [gs://bucket/prefix.git|s3://bucket/prefix.git] - web [--addr 127.0.0.1] [--port 8042] [--local] - admin grant-read|grant-write|grant-admin IDENTITY - create-gcloud-profile NAME - fetch | pull | push | ls-remote - status | add | commit | checkout | branch | merge | tag - diff | log | show | reset | restore | stash | revert - grep | blame | cherry-pick | clean | describe - ls-files | ls-tree | archive | config | rev-parse | rm | mv - -direct GCS mode: - bgit --bucket BUCKET --prefix PREFIX ls [prefix] - bgit --bucket BUCKET --prefix PREFIX cat [--commit SHA] path - bgit --bucket BUCKET --prefix PREFIX log [--limit N] [--skip N] [--path PATH] - put path [--file FILE] -m MSG --author NAME --email EMAIL +These are common BucketGit commands: + +start a repository + setup Connect a cloud account and deploy or update BucketGit + init Create a local Git repository backed by BucketGit + clone Clone a BucketGit repository into a new directory + +work on the current change + add Add file contents to the index + mv Move or rename a file, directory, or symlink + restore Restore working tree files + rm Remove files from the working tree and index + +examine history and state + diff Show changes between commits, commit and working tree, etc + grep Print lines matching a pattern + log Show commit logs + show Show objects + status Show the working tree status + +grow, mark, and tweak history + branch List, create, or delete branches + checkout Switch branches or restore paths + commit Record changes to the repository + merge Join development histories together + reset Reset HEAD, index, or working tree state + tag Create, list, delete, or verify tags + +collaborate + fetch Download objects and refs from BucketGit + pull Fetch and integrate with the current branch + push Update remote refs and upload objects + ls-remote List remote refs + pr Create, review, merge, and close pull requests + +administer + admin Manage broker-backed users, keys, owners, and protection + broker Delete or decommission deployed broker infrastructure + web Browse a repository locally global options: --profile NAME - --auth gcloud|adc --version + +Legacy direct bucket operations are under "bgit direct". +Run "bgit help " or "bgit direct help" for details. `) return err } @@ -619,101 +855,133 @@ func commandHelp(cmd string, stdout io.Writer) error { func helpPages() map[string]string { return map[string]string{ - "clone": `usage: bgit clone gs://bucket/prefix.git [directory] - bgit clone s3://bucket/prefix.git [directory] + "clone": `usage: + bgit clone [directory] + bgit clone https://broker.example.com/team/app.git [directory] + bgit clone --broker https://broker.example.com team/app.git [directory] -Clone a bucketgit repository from object storage into a local worktree. -The origin is stored in .git/config so later bgit fetch, pull, and push -commands can infer it. +Clone a BucketGit repository by logical repo name. Passing a broker URL makes +the checkout self-contained and does not require a local profile. Direct +object-storage clone moved to bgit direct clone. examples: - bgit clone gs://my-bucket/repositories/app.git - bgit clone s3://my-bucket/repositories/app.git --profile aws-profile - bgit clone gs://my-bucket/repositories/app.git ./app - bgit --branch develop clone gs://my-bucket/repositories/app.git + bgit clone team/app.git + bgit clone https://bgit-broker.example.com/team/app.git + bgit direct clone gs://my-bucket/repositories/app.git + bgit direct clone s3://my-bucket/repositories/app.git --profile aws-profile `, - "init": `usage: bgit init [directory] + "init": `usage: + bgit init + bgit init --noninteractive --repo NAME --profile PROFILE[.REGION] [--region REGION] [directory] -Create a local Git repository. This does not require an origin. Configure one -later with bgit origin before pushing. +Create a local Git repository and attach it to a BucketGit repository from +~/.bgit/config.yaml. Without --noninteractive, init prompts for missing repo, +profile, and region choices. examples: bgit init - bgit init ./app - bgit origin gs://my-bucket/repositories/app.git - bgit push + bgit init --noninteractive --repo app --profile gcp:work.europe-west1 + bgit init --noninteractive --repo app --profile work --region europe-west1 +`, + "setup": `usage: + bgit setup + bgit setup --yes [--provider gcp|aws] [--profile NAME] [--key PATH] [--region REGION] + bgit setup profile create --provider gcp|aws NAME + +Discover cloud profiles, deploy or update a bgit broker, import owner SSH keys, +and write the global BucketGit config at ~/.bgit/config.yaml. + +GCP profiles are discovered from gcloud configurations. AWS profiles are +discovered from AWS config/credentials files and aws configure list-profiles +when the AWS CLI is available. + +examples: + bgit setup + bgit setup profile create --provider gcp work + bgit setup --yes --provider gcp --profile work --key ~/.ssh/id_ed25519.pub + bgit setup --yes --provider aws --profile production --region us-east-1 +`, + "broker": `usage: + bgit broker delete --provider gcp --profile NAME [--region REGION] [--data] --yes + bgit broker delete --provider aws --profile NAME [--region REGION] --yes + +Delete deployed bgit broker infrastructure for a selected cloud profile. +AWS deletes the CloudFormation stack and waits for deletion. GCP deletes the +Gen2 function/Cloud Run service; pass --data to also delete the Firestore +broker database. `, "origin": `usage: - bgit origin gs://bucket/prefix.git - bgit origin s3://bucket/prefix.git + bgit direct origin gs://bucket/prefix.git + bgit direct origin s3://bucket/prefix.git -Set the bucketgit origin for the current local Git repository. This also +Set a direct bucketgit origin for the current local Git repository. This also sets the regular Git remote named origin to the same URL for visibility. examples: - bgit origin gs://my-bucket/repositories/app.git - bgit origin s3://my-bucket/repositories/app.git --profile aws-profile + bgit direct origin gs://my-bucket/repositories/app.git + bgit direct origin s3://my-bucket/repositories/app.git --profile aws-profile git remote -v `, "remote": `usage: - bgit remote add origin gs://bucket/prefix.git - bgit remote add origin s3://bucket/prefix.git - bgit remote set-url origin gs://bucket/prefix.git + bgit direct remote add origin gs://bucket/prefix.git + bgit direct remote add origin s3://bucket/prefix.git + bgit direct remote set-url origin gs://bucket/prefix.git -Configure the bucketgit origin using Git remote syntax. +Configure a direct bucketgit origin using Git remote syntax. `, "admin": `usage: - bgit admin grant-read IDENTITY - bgit admin grant-write IDENTITY - bgit admin grant-admin IDENTITY - bgit admin make-public - bgit admin make-private - bgit admin --bucket BUCKET grant-write IDENTITY - -Grant bucket access or toggle public read access for GCS or S3 repositories. -Run inside a bgit checkout to infer the bucket and prefix, or pass --bucket -explicitly. - -For GCS, IDENTITY may be user@example.com, user:user@example.com, -serviceAccount:name@project.iam.gserviceaccount.com, group:team@example.com, -allUsers, or allAuthenticatedUsers. - -For S3, IDENTITY must be an IAM/STS ARN, a 12 digit AWS account ID, or *. + bgit admin keys list|add|remove|suspend|import-github [args] + bgit admin owner transfer --user USER KEY_OR_FINGERPRINT + bgit admin protect add|list|remove [ref] -grant-read grants object read access plus bucket/prefix listing. -grant-write grants object read/write/delete access plus bucket/prefix listing. -grant-admin grants storage admin access on the bucket or repository prefix. -make-public grants anonymous read access. -make-private removes bgit-managed anonymous read access. +Broker-backed repository administration. Cloud IAM and bucket-policy +administration moved to bgit direct admin. examples: - bgit admin grant-read user:dev@example.com - bgit admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com - bgit admin --bucket my-bucket grant-admin admin@example.com - bgit admin make-public - bgit admin make-private - bgit admin --bucket s3://my-bucket/repositories/app.git grant-read arn:aws:iam::123456789012:role/Developer + bgit admin keys list + bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub + bgit admin keys import-github octocat --role read + bgit admin protect add main + bgit direct admin grant-read user:dev@example.com +`, + "pr": `usage: + bgit pr create [--title TITLE] [--body BODY] [--source BRANCH] [--target BRANCH] + bgit pr list + bgit pr view ID + bgit pr checkout ID + bgit pr diff ID + bgit pr merge ID + bgit pr close ID + +Broker-backed pull request metadata and merge/ref protection workflow. +Pull requests are stored in the broker control plane, not in Git itself. +`, + "direct": `usage: + bgit direct help + bgit direct clone gs://bucket/prefix.git [directory] + bgit direct clone s3://bucket/prefix.git [directory] + bgit direct origin gs://bucket/prefix.git + bgit direct remote add origin s3://bucket/prefix.git + bgit direct fetch|pull|push|ls-remote + bgit direct ls|cat|show|log|put [args] + bgit direct admin grant-read|grant-write|grant-admin IDENTITY + +Low-level object-storage and cloud IAM escape hatch for legacy direct bucket +operations, recovery, migration, and debugging. Normal BucketGit workflows +should use setup, init, git transport, and admin commands. + +Direct mode also owns --bucket/--prefix and --auth gcloud|adc. `, "ssh": `usage: - bgit ssh setup [--broker URL] [--region REGION] [--firestore-database NAME] [--firestore-location LOCATION] [--key PATH] [--no-agent] [gs://bucket/prefix.git|s3://bucket/prefix.git] - bgit ssh scaffold [--broker URL] [gs://bucket/prefix.git|s3://bucket/prefix.git] - bgit ssh repo add [--broker URL] [--key PATH] [repo] - bgit ssh keys list|add|remove|suspend [--broker URL] [--key PATH] [repo] - -Configure the current repository so normal git fetch/push uses bgit as the SSH -transport command. The setup command also records public keys from ssh-agent or ---key for a future broker-backed authorization flow. When --broker is omitted, -setup looks for an existing bgit-broker endpoint in the selected cloud account -and region. Setup also upserts the repository into the broker with discovered -SSH identities under an admin user. + bgit ssh git-upload-pack + bgit ssh git-receive-pack + +Internal SSH transport used by Git for BucketGit remotes. Most users should not +run this command directly; bgit init writes the required core.sshCommand config. examples: - bgit ssh setup gs://my-bucket/repositories/app.git - bgit ssh setup s3://my-bucket/repositories/app.git --profile aws-profile --key ~/.ssh/id_ed25519.pub - bgit ssh repo add --key ~/.ssh/id_ed25519.pub - bgit ssh keys add --user ada --role write --key ~/.ssh/ada.pub - bgit ssh keys list - bgit ssh scaffold + git fetch origin + git push origin main `, "web": `usage: bgit web [--addr ADDR] [--port PORT] [--local] @@ -727,15 +995,6 @@ examples: bgit web bgit web --port 8042 bgit web --local -`, - "create-gcloud-profile": `usage: bgit create-gcloud-profile [--yes] NAME - -Create a gcloud configuration, run gcloud auth login for that configuration, -and save it as bucketgit.profile in the current checkout when run inside one. - -examples: - bgit create-gcloud-profile my-profile - bgit create-gcloud-profile --yes my-profile `, "fetch": `usage: bgit fetch @@ -766,21 +1025,21 @@ examples: List refs from the configured object-storage repository. `, - "ls": `usage: bgit --bucket BUCKET --prefix PREFIX ls [path-prefix] + "ls": `usage: bgit --bucket BUCKET --prefix PREFIX direct ls [path-prefix] -Direct GCS mode: list files at the configured branch without a checkout. +Direct bucket mode: list files at the configured branch without a checkout. `, - "list": `usage: bgit --bucket BUCKET --prefix PREFIX list [path-prefix] + "list": `usage: bgit --bucket BUCKET --prefix PREFIX direct list [path-prefix] -Direct GCS mode: list files at the configured branch without a checkout. +Direct bucket mode: list files at the configured branch without a checkout. `, - "cat": `usage: bgit --bucket BUCKET --prefix PREFIX cat [--commit SHA] path + "cat": `usage: bgit --bucket BUCKET --prefix PREFIX direct cat [--commit SHA] path -Direct GCS mode: print one file from the configured branch or commit. +Direct bucket mode: print one file from the configured branch or commit. `, - "put": `usage: bgit --bucket BUCKET --prefix PREFIX put path [--file FILE] -m MSG --author NAME --email EMAIL + "put": `usage: bgit --bucket BUCKET --prefix PREFIX direct put path [--file FILE] -m MSG --author NAME --email EMAIL -Direct GCS mode: write one file and commit it to the GCS-backed repository. +Direct bucket mode: write one file and commit it to the bucket-backed repository. Use --file - or omit --file to read content from stdin. `, } @@ -827,16 +1086,16 @@ func createGcloudProfileCommand(args []string, stdin io.Reader, stdout io.Writer yes = true default: if strings.HasPrefix(arg, "-") { - return fmt.Errorf("unsupported create-gcloud-profile option %s", arg) + return fmt.Errorf("unsupported setup profile create option %s", arg) } if profile != "" { - return errors.New("create-gcloud-profile accepts exactly one profile name") + return errors.New("setup profile create accepts exactly one profile name") } profile = arg } } if profile == "" { - return errors.New("create-gcloud-profile requires a profile name") + return errors.New("setup profile create requires a profile name") } if !yes { fmt.Fprintf(stdout, "Create gcloud configuration %q, run browser login, and save it in this checkout if possible? [y/N] ", profile) @@ -864,20 +1123,90 @@ func createGcloudProfileCommand(args []string, stdin io.Reader, stdout io.Writer return nil } +func createAWSProfileCommand(args []string, stdin io.Reader, stdout io.Writer) error { + yes := false + var profile string + for _, arg := range args { + switch arg { + case "-y", "--yes": + yes = true + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported setup profile create option %s", arg) + } + if profile != "" { + return errors.New("setup profile create accepts exactly one profile name") + } + profile = arg + } + } + if profile == "" { + return errors.New("setup profile create requires a profile name") + } + if !yes { + fmt.Fprintf(stdout, "Create or update AWS profile %q with aws configure? [y/N] ", profile) + var answer string + _, _ = fmt.Fscanln(stdin, &answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + return errors.New("aborted") + } + } + if err := runAWSProfileCommand(stdout, "configure", "--profile", profile); err != nil { + return err + } + fmt.Fprintf(stdout, "created AWS profile %s\n", profile) + return nil +} + +func createAWSProfileConfigured(profile, accessKey, secretKey, region string, stdout io.Writer) error { + profile = strings.TrimSpace(profile) + accessKey = strings.TrimSpace(accessKey) + secretKey = strings.TrimSpace(secretKey) + region = strings.TrimSpace(region) + if profile == "" { + return errors.New("setup profile create requires a profile name") + } + if accessKey == "" { + return errors.New("AWS access key ID is required") + } + if secretKey == "" { + return errors.New("AWS secret access key is required") + } + fmt.Fprintf(stdout, "configuring AWS profile %s\n", profile) + if err := runAWSProfileCommand(stdout, "configure", "set", "aws_access_key_id", accessKey, "--profile", profile); err != nil { + return err + } + if err := runAWSProfileCommand(stdout, "configure", "set", "aws_secret_access_key", secretKey, "--profile", profile); err != nil { + return err + } + if region != "" { + if err := runAWSProfileCommand(stdout, "configure", "set", "region", region, "--profile", profile); err != nil { + return err + } + } + fmt.Fprintf(stdout, "created AWS profile %s\n", profile) + return nil +} + func runGcloudProfileCommand(stdout io.Writer, args ...string) error { cmd := exec.Command("gcloud", args...) cmd.Stdin = os.Stdin - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out + cmd.Stdout = stdout + cmd.Stderr = stdout if err := cmd.Run(); err != nil { - if out.Len() > 0 { - _, _ = stdout.Write(out.Bytes()) - } return fmt.Errorf("gcloud %s: %w", strings.Join(args, " "), err) } - if out.Len() > 0 { - _, _ = stdout.Write(out.Bytes()) + return nil +} + +func runAWSProfileCommand(stdout io.Writer, args ...string) error { + cmd := exec.Command("aws", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("aws %s failed: %w", strings.Join(args, " "), err) } return nil } @@ -1022,6 +1351,12 @@ func writeBucketGitConfig(worktree string, cfg config) error { {"bucketgit.prefix", cfg.prefix}, {"bucketgit.branch", cfg.branch}, } + if strings.TrimSpace(cfg.auth) != "" && cfg.auth != defaultAuthMode { + pairs = append(pairs, []string{"bucketgit.auth", cfg.auth}) + } + if strings.TrimSpace(cfg.gcloudConfiguration) != "" { + pairs = append(pairs, []string{"bucketgit.profile", cfg.gcloudConfiguration}) + } for _, pair := range pairs { if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { return err @@ -1042,6 +1377,41 @@ func setGitOrigin(worktree string, origin string) error { return err } +func setGitBranchTracking(worktree, branch, remote string) error { + branch = shortBranchName(firstNonEmpty(branch, defaultBranch)) + remote = firstNonEmpty(strings.TrimSpace(remote), "origin") + pairs := [][]string{ + {"branch." + branch + ".remote", remote}, + {"branch." + branch + ".merge", branchRef(branch)}, + } + for _, pair := range pairs { + if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } + return nil +} + +func setGitBranchTrackingIfOrigin(worktree, branch string) error { + if _, err := runGit(worktree, "remote", "get-url", "origin"); err != nil { + return nil + } + return setGitBranchTracking(worktree, branch, "origin") +} + +func unsetGitBranchTracking(worktree, branch string) error { + branch = shortBranchName(strings.TrimSpace(branch)) + if branch == "" { + return nil + } + for _, key := range []string{"branch." + branch + ".remote", "branch." + branch + ".merge"} { + if _, err := runGit(worktree, "config", "--local", "--unset-all", key); err != nil { + continue + } + } + return nil +} + func originForConfig(cfg config) string { if cfg.origin != "" { return cfg.origin @@ -1129,6 +1499,7 @@ type pushOptions struct { force bool delete bool skipBroker bool + remote string refs []string } @@ -1142,6 +1513,10 @@ func parsePushArgs(args []string) (pushOptions, error) { opts.force = true case "--delete", "-d": opts.delete = true + case "--set-upstream", "-u": + // bgit records the configured remote in the worktree. Accept Git's + // common upstream flag for CLI compatibility, but there is nothing + // extra to persist in the object-store ref update path. case "--skip-broker": opts.skipBroker = true default: @@ -1154,6 +1529,10 @@ func parsePushArgs(args []string) (pushOptions, error) { } } } + if len(opts.refs) > 0 && opts.refs[0] == "origin" { + opts.remote = opts.refs[0] + opts.refs = opts.refs[1:] + } if opts.delete && len(opts.refs) == 0 { return opts, errors.New("push --delete requires at least one branch or ref") } diff --git a/main_test.go b/main_test.go index dd34037..9c5e341 100644 --- a/main_test.go +++ b/main_test.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -186,7 +185,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"help", "clone"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone help = %q", stdout.String()) } @@ -194,7 +193,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"clone", "help"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone help alias = %q", stdout.String()) } @@ -202,7 +201,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"--help", "clone"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("--help clone = %q", stdout.String()) } @@ -210,16 +209,16 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"clone", "--help"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone --help = %q", stdout.String()) } stdout.Reset() - if err := run([]string{"help", "create-gcloud-profile"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { + if err := run([]string{"help", "setup"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit create-gcloud-profile") { - t.Fatalf("create-gcloud-profile help = %q", stdout.String()) + if !strings.Contains(stdout.String(), "bgit setup profile create") { + t.Fatalf("setup help = %q", stdout.String()) } } @@ -234,6 +233,17 @@ func TestCreateGcloudProfileCommandRequiresConfirmation(t *testing.T) { } } +func TestCreateAWSProfileCommandRequiresConfirmation(t *testing.T) { + var stdout bytes.Buffer + err := createAWSProfileCommand([]string{"default"}, strings.NewReader("n\n"), &stdout) + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("expected aborted, got %v", err) + } + if !strings.Contains(stdout.String(), "Create or update AWS profile") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + func TestConfigHelp(t *testing.T) { var stdout bytes.Buffer if err := run([]string{"help", "config"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { @@ -523,6 +533,27 @@ func TestParsePushArgs(t *testing.T) { } } +func TestParsePushArgsAcceptsGitRemoteShape(t *testing.T) { + opts, err := parsePushArgs([]string{"-u", "origin", "feature/protection-check"}) + if err != nil { + t.Fatal(err) + } + if opts.remote != "origin" { + t.Fatalf("remote = %q", opts.remote) + } + if len(opts.refs) != 1 || opts.refs[0] != "feature/protection-check" { + t.Fatalf("refs = %#v", opts.refs) + } + + opts, err = parsePushArgs([]string{"--set-upstream", "origin"}) + if err != nil { + t.Fatal(err) + } + if opts.remote != "origin" || len(opts.refs) != 0 { + t.Fatalf("opts = %#v", opts) + } +} + func TestNoRefsErrorDetection(t *testing.T) { err := errors.New("git --git-dir /tmp/repo.git show-ref --tags: exit status 1") if !isNoRefs(err) { @@ -623,6 +654,18 @@ func TestInitWorktreeCreatesGitCheckout(t *testing.T) { if strings.TrimSpace(string(originOut)) != "gs://bucket/repos/demo.git" { t.Fatalf("origin = %q", string(originOut)) } + for key, want := range map[string]string{ + "branch.master.remote": "origin", + "branch.master.merge": "refs/heads/master", + } { + out, err := runGit(target, "config", "--local", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if got := strings.TrimSpace(string(out)); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } } func TestOriginCommandWritesLocalConfigAndGitRemote(t *testing.T) { @@ -695,114 +738,7 @@ func TestOriginCommandWritesS3Provider(t *testing.T) { } } -func TestSSHSetupWritesLocalConfigAndGitRemote(t *testing.T) { - target := t.TempDir() - if _, err := runGit("", "init", target); err != nil { - t.Fatal(err) - } - keyPath := filepath.Join(t.TempDir(), "id_ed25519.pub") - if err := os.WriteFile(keyPath, []byte("ssh-ed25519 AAAATESTKEY ada@example.com\n"), 0o644); err != nil { - t.Fatal(err) - } - oldDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(oldDir) - if err := os.Chdir(target); err != nil { - t.Fatal(err) - } - var upsert brokerRepoRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/repos/upsert" { - t.Fatalf("unexpected broker path %s", r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&upsert); err != nil { - t.Fatal(err) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - var stdout bytes.Buffer - err = sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{ - "setup", - "gs://bucket-name/path/repo.git", - "--no-agent", - "--key", keyPath, - "--broker", server.URL, - }, &stdout, ioDiscard{}) - if err != nil { - t.Fatal(err) - } - remoteOut, err := runGit(target, "remote", "get-url", "origin") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(remoteOut)); got != "git@git.bucketgit.com:bucket-name/path/repo.git" { - t.Fatalf("remote origin = %q", got) - } - for key, want := range map[string]string{ - "core.sshCommand": "bgit ssh", - "bucketgit.origin": "gs://bucket-name/path/repo.git", - "bucketgit.sshHost": "git.bucketgit.com", - "bucketgit.broker": server.URL, - "bucketgit.sshkey1": "ssh-ed25519 AAAATESTKEY ada@example.com", - } { - out, err := runGit(target, "config", "--local", key) - if err != nil { - t.Fatalf("read %s: %v", key, err) - } - if got := strings.TrimSpace(string(out)); got != want { - t.Fatalf("%s = %q, want %q", key, got, want) - } - } - if !strings.Contains(stdout.String(), "configured SSH origin git@git.bucketgit.com:bucket-name/path/repo.git") { - t.Fatalf("stdout = %q", stdout.String()) - } - if upsert.Repo.Origin != "gs://bucket-name/path/repo.git" || upsert.AdminUser != "admin" || upsert.Role != "admin" { - t.Fatalf("upsert = %#v", upsert) - } - if len(upsert.PublicKeys) != 1 || upsert.PublicKeys[0] != "ssh-ed25519 AAAATESTKEY ada@example.com" { - t.Fatalf("upsert keys = %#v", upsert.PublicKeys) - } -} - -func TestSSHScaffoldInfersExistingOrigin(t *testing.T) { - target := t.TempDir() - if _, err := runGit("", "init", target); err != nil { - t.Fatal(err) - } - oldDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(oldDir) - if err := os.Chdir(target); err != nil { - t.Fatal(err) - } - if err := originCommand([]string{"s3://bucket-name/path/repo.git"}, ioDiscard{}); err != nil { - t.Fatal(err) - } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"scaffold"}, ioDiscard{}, ioDiscard{}); err != nil { - t.Fatal(err) - } - remoteOut, err := runGit(target, "remote", "get-url", "origin") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(remoteOut)); got != "git@git.bucketgit.com:bucket-name/path/repo.git" { - t.Fatalf("remote origin = %q", got) - } - providerOut, err := runGit(target, "config", "--local", "bucketgit.provider") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(providerOut)); got != "s3" { - t.Fatalf("provider = %q", got) - } -} - -func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { +func TestSSHKeysCommandsUseBroker(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", target); err != nil { t.Fatal(err) @@ -816,7 +752,7 @@ func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { requests = append(requests, r.URL.Path) w.Header().Set("content-type", "application/json") switch r.URL.Path { - case "/repos/upsert", "/keys/add", "/keys/remove", "/keys/suspend": + case "/keys/add", "/keys/remove", "/keys/suspend": w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) case "/keys/list": @@ -841,26 +777,23 @@ func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { if _, err := runGit(target, "config", "--local", "bucketgit.broker", server.URL); err != nil { t.Fatal(err) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"repo", "add", "--no-agent", "--key", keyPath}, ioDiscard{}, ioDiscard{}); err != nil { - t.Fatal(err) - } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "add", "--no-agent", "--key", keyPath, "--user", "ada", "--role", "write"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"add", "--no-agent", "--key", keyPath, "--user", "ada", "--role", "write"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } var stdout bytes.Buffer - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "list"}, &stdout, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"list"}, strings.NewReader(""), &stdout); err != nil { t.Fatal(err) } if !strings.Contains(stdout.String(), "admin\tadmin\tactive\tssh-ed25519 AAAAADMIN admin@example.com") { t.Fatalf("keys list stdout = %q", stdout.String()) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "suspend", "AAAAADMIN"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"suspend", "AAAAADMIN"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "remove", "AAAAADMIN"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"remove", "AAAAADMIN"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } - want := []string{"/repos/upsert", "/keys/add", "/keys/list", "/keys/suspend", "/keys/remove"} + want := []string{"/keys/add", "/keys/list", "/keys/suspend", "/keys/remove"} if strings.Join(requests, ",") != strings.Join(want, ",") { t.Fatalf("requests = %#v", requests) } @@ -1067,11 +1000,18 @@ func TestProvisionGCPBrokerURLDeploysThenDiscoversFunction(t *testing.T) { bin := t.TempDir() marker := filepath.Join(t.TempDir(), "deployed") writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ - {match: "functions describe bgit-broker", stdout: "https://bgit-broker-provisioned.example.test", requireFile: marker, exitCode: 1}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: "https://bgit-broker-provisioned.example.test", requireFile: marker, exitCode: 1}, {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, {match: "firestore databases describe", exitCode: 1}, {match: "firestore databases create"}, - {match: "functions deploy bgit-broker", touch: marker}, + {match: "config get-value project", stdout: "project-id"}, + {match: "config get-value account", stdout: "ada@example.com"}, + {match: "iam service-accounts describe bgit-broker@project-id.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding project-id --member=serviceAccount:bgit-broker@project-id.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@project-id.iam.gserviceaccount.com", touch: marker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@project-id.iam.gserviceaccount.com"}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) var stdout bytes.Buffer @@ -1130,11 +1070,13 @@ func TestProvisionAWSBrokerURLDeploysThenDiscoversStackOutput(t *testing.T) { } type fakeCLIAction struct { - match string - stdout string - exitCode int - touch string - requireFile string + match string + stdout string + missingStdout string + exitCode int + touch string + requireFile string + onlyIfFile string } func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { @@ -1156,9 +1098,15 @@ func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { if action.requireFile != "" { script.WriteString(" if not exist \"") script.WriteString(escapeBatch(action.requireFile)) - script.WriteString("\" exit /b ") + script.WriteString("\" (\r\n") + if action.missingStdout != "" { + script.WriteString(" echo ") + script.WriteString(action.missingStdout) + script.WriteString("\r\n") + } + script.WriteString(" exit /b ") script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString("\r\n") + script.WriteString("\r\n )\r\n") } if action.touch != "" { script.WriteString(" type nul > \"") @@ -1177,18 +1125,30 @@ func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { script.WriteString("exit /b 1\r\n") } else { script.WriteString("#!/bin/sh\n") - script.WriteString("case \"$*\" in\n") + script.WriteString("ARGS=\"$*\"\n") for _, action := range actions { finalExitCode := fakeCLIFinalExitCode(action) - script.WriteString(" *\"") + script.WriteString("case \"$ARGS\" in *\"") script.WriteString(strings.ReplaceAll(action.match, `"`, `\"`)) script.WriteString("\"*) ") + if action.onlyIfFile != "" { + script.WriteString("if [ -f '") + script.WriteString(strings.ReplaceAll(action.onlyIfFile, `'`, `'\''`)) + script.WriteString("' ]; then ") + } if action.requireFile != "" { script.WriteString("[ -f '") script.WriteString(strings.ReplaceAll(action.requireFile, `'`, `'\''`)) - script.WriteString("' ] || exit ") + script.WriteString("' ] || { ") + if action.missingStdout != "" { + script.WriteString("printf '%s\\n' '") + script.WriteString(strings.ReplaceAll(action.missingStdout, `'`, `'\''`)) + script.WriteString("'") + script.WriteString(" ; ") + } + script.WriteString("exit ") script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString(" ; ") + script.WriteString(" ; } ; ") } if action.touch != "" { script.WriteString("touch '") @@ -1196,16 +1156,19 @@ func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { script.WriteString("' ; ") } if action.stdout != "" { - script.WriteString("echo ") - script.WriteString(action.stdout) + script.WriteString("printf '%s\\n' '") + script.WriteString(strings.ReplaceAll(action.stdout, `'`, `'\''`)) + script.WriteString("'") script.WriteString(" ; ") } script.WriteString("exit ") script.WriteString(strconv.Itoa(finalExitCode)) - script.WriteString(" ;;\n") + if action.onlyIfFile != "" { + script.WriteString(" ; fi") + } + script.WriteString(" ;; esac\n") } - script.WriteString(" *) exit 1 ;;\n") - script.WriteString("esac\n") + script.WriteString("exit 1\n") } if err := os.WriteFile(path, []byte(script.String()), 0o755); err != nil { t.Fatal(err) @@ -1251,6 +1214,8 @@ func TestAWSBrokerCloudFormationTemplateHasBrokerOutput(t *testing.T) { "/refs/update", "roleAllows", "ConditionalCheckFailedException", + "BROKER_VERSION: " + brokerVersion, + `version: brokerVersion`, } { if !strings.Contains(template, want) { t.Fatalf("template missing %q:\n%s", want, template) @@ -1280,6 +1245,8 @@ func TestGCPBrokerSourceUsesFirestoreAndSignatureHeaders(t *testing.T) { "/refs/update", "roleAllows", "runTransaction", + "process.env.BROKER_VERSION", + "version: brokerVersion", } { if !strings.Contains(string(index), want) { t.Fatalf("GCP broker source missing %q:\n%s", want, string(index)) @@ -1369,6 +1336,31 @@ func TestReadLocalConfigFallsBackToGCSRemoteOrigin(t *testing.T) { } } +func TestWriteBucketGitConfigPersistsSelectedAuthDefaults(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", target); err != nil { + t.Fatal(err) + } + cfg := config{ + provider: "gcs", + bucket: "bucket-name", + prefix: "path/repo.git", + branch: defaultBranch, + auth: "adc", + gcloudConfiguration: "work", + } + if err := writeBucketGitConfig(target, cfg); err != nil { + t.Fatal(err) + } + got, err := readLocalConfig(target) + if err != nil { + t.Fatal(err) + } + if got.gcloudConfiguration != "work" || got.auth != "adc" { + t.Fatalf("cfg = %#v", got) + } +} + func TestMissingOriginErrorIncludesCopyPasteCommands(t *testing.T) { err := missingOriginError() if err == nil { @@ -1377,9 +1369,9 @@ func TestMissingOriginErrorIncludesCopyPasteCommands(t *testing.T) { text := err.Error() for _, want := range []string{ "No configured push destination.", - "bgit origin gs://bucket-name/path/to/repo.git", - "bgit push", - "bgit --bucket bucket-name --prefix path/to/repo.git push", + "bgit direct origin gs://bucket-name/path/to/repo.git", + "bgit direct push", + "bgit --bucket bucket-name --prefix path/to/repo.git direct push", } { if !strings.Contains(text, want) { t.Fatalf("missing %q in:\n%s", want, text) @@ -1400,7 +1392,7 @@ func TestPushWithoutOriginReportsSetupBeforeGCSClient(t *testing.T) { if err := os.Chdir(target); err != nil { t.Fatal(err) } - err = run([]string{"push"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}) + err = run([]string{"direct", "push"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}) if err == nil { t.Fatal("expected missing origin error") } @@ -2052,6 +2044,170 @@ func TestCommitCheckoutBranchAndLogWorkWithoutOrigin(t *testing.T) { } } +func TestLocalBranchLifecycleMaintainsOriginTracking(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "user.name", "Ada"}, + {"config", "user.email", "ada@example.com"}, + {"remote", "add", "origin", "git@git.bucketgit.com:team/app.git"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := localGitCommand("add", []string{"README.md"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := localGitCommand("commit", []string{"-m", "Initial"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := localGitCommand("checkout", []string{"-b", "feature/web"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "branch.feature/web.remote": "origin", + "branch.feature/web.merge": "refs/heads/feature/web", + } { + out, err := runGit(target, "config", "--local", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if got := strings.TrimSpace(string(out)); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } + if err := localGitCommand("checkout", []string{"main"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := localGitCommand("branch", []string{"-D", "feature/web"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "config", "--local", "--get", "branch.feature/web.remote"); err == nil { + t.Fatal("branch.feature/web.remote still configured after branch delete") + } + if _, err := runGit(target, "config", "--local", "--get", "branch.feature/web.merge"); err == nil { + t.Fatal("branch.feature/web.merge still configured after branch delete") + } +} + +func TestCheckoutCarriesCompatibleLocalChanges(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Ada"}, {"config", "user.email", "ada@example.com"}} { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("TEST\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "Initial"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("TEST\nTEST2\n"), 0o644); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := localGitCommand("checkout", []string{"main"}, &stdout); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "TEST\nTEST2\n" { + t.Fatalf("README.md = %q", string(data)) + } + if !strings.Contains(stdout.String(), "Switched to branch 'main'") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestCheckoutRejectsOverwrittenLocalChanges(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Ada"}, {"config", "user.email", "ada@example.com"}} { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "main"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("branch\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-am", "branch"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + err = localGitCommand("checkout", []string{"main"}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "would be overwritten by checkout") { + t.Fatalf("err = %v", err) + } + if branch, _ := runGit(target, "branch", "--show-current"); strings.TrimSpace(string(branch)) != "barfoo" { + t.Fatalf("branch = %q", string(branch)) + } + data, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "dirty\n" { + t.Fatalf("README.md = %q", string(data)) + } +} + func TestExpandedNativePorcelainCommands(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { @@ -2288,6 +2444,31 @@ func TestNativeConfigCommand(t *testing.T) { } } +func TestGlobalIdentityConfigCommand(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := globalConfigCommand([]string{"--global", "user.name", "Dennis Example"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := globalConfigCommand([]string{"--global", "user.email", "dennis@example.com"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := globalConfigCommand([]string{"--global", "--get", "user.name"}, &stdout); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout.String()) != "Dennis Example" { + t.Fatalf("user.name = %q", stdout.String()) + } + cfg, err := readGlobalConfig(filepath.Join(home, ".bgit", "config.yaml")) + if err != nil { + t.Fatal(err) + } + if cfg.Identity.Name != "Dennis Example" || cfg.Identity.Email != "dennis@example.com" { + t.Fatalf("identity = %#v", cfg.Identity) + } +} + func TestPushUpdatesBareRepo(t *testing.T) { root := t.TempDir() bare := filepath.Join(root, "repo.git") @@ -2336,6 +2517,60 @@ func TestPushUpdatesBareRepo(t *testing.T) { } } +func TestNativeDiffSupportsRevisionOperands(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "config", "user.name", "Ada"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "config", "user.email", "ada@example.com"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "main"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\nbarfoo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-am", "barfoo"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "main"); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := localGitCommand("diff", []string{"barfoo"}, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "-barfoo") { + t.Fatalf("diff barfoo = %q", stdout.String()) + } + stdout.Reset() + err = localGitCommand("diff", []string{"foobar"}, &stdout) + if err == nil || !strings.Contains(err.Error(), `unknown revision "foobar"`) { + t.Fatalf("err = %v stdout=%q", err, stdout.String()) + } +} + func TestNativeResetHardSupportsHeadAncestorAndRemovesFiles(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { @@ -2508,6 +2743,14 @@ func TestNativeGitRepoPushWritesObjectsAndRefsWithoutBareSync(t *testing.T) { if strings.ReplaceAll(stdout.String(), "\r\n", "\n") != "# Demo\n" { t.Fatalf("remote cat = %q", stdout.String()) } + + stdout.Reset() + if err := repo.push(context.Background(), nil, &stdout); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout.String()) != "Everything up-to-date" { + t.Fatalf("second push stdout = %q", stdout.String()) + } } func TestNativeGitRepoPushTagsDoesNotMoveConfiguredBranch(t *testing.T) { @@ -2608,6 +2851,64 @@ func TestNativeGitRepoFetchCopiesObjectsAndRemoteRefs(t *testing.T) { } } +func TestNativeGitRepoPushDefaultsToCurrentBranch(t *testing.T) { + root := t.TempDir() + remoteRoot := filepath.Join(root, "remote.git") + worktree := filepath.Join(root, "worktree") + if err := os.MkdirAll(remoteRoot, 0o755); err != nil { + t.Fatal(err) + } + if _, err := runGit("", "init", "--initial-branch", "main", worktree); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "config", "user.name", "Ada"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "config", "user.email", "ada@example.com"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# Demo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "commit", "-m", "Initial commit"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# Demo\n\nbarfoo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "commit", "-am", "Barfoo"); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(worktree); err != nil { + t.Fatal(err) + } + repo := newNativeGitRepoForStore(config{branch: "main", origin: "gs://bucket/repo.git"}, &localGitStore{root: remoteRoot}) + var stdout bytes.Buffer + if err := repo.push(context.Background(), nil, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "barfoo -> barfoo") { + t.Fatalf("stdout = %q", stdout.String()) + } + if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "barfoo")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "main")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("main ref err = %v", err) + } +} + func TestNativeGitRepoPutFileCommitsAndPushesWithoutBareSync(t *testing.T) { root := t.TempDir() remoteRoot := filepath.Join(root, "remote.git") @@ -2750,6 +3051,256 @@ func TestWebHandlerRendersBranchSelector(t *testing.T) { } } +func TestWebHandlerServesJSONAPI(t *testing.T) { + bare := createBareFixture(t) + repo := newNativeGitRepoForStore(config{branch: "main", origin: "gs://bucket/repo.git"}, &localGitStore{root: bare}) + handler := newWebHandler(repo, config{branch: "main", origin: "gs://bucket/repo.git"}) + + for _, tc := range []struct { + path string + want []string + }{ + {path: "/api/refs", want: []string{`"full_name":"refs/heads/main"`}}, + {path: "/api/tree?path=docs", want: []string{`"path":"docs/guide.md"`, `"kind":"file"`}}, + {path: "/api/blob?path=README.md", want: []string{`"encoding":"utf-8"`, `"content":"# Demo\n"`}}, + {path: "/api/commits", want: []string{`"subject":"Initial commit"`}}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", tc.path, rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("%s content-type = %q", tc.path, got) + } + for _, want := range tc.want { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("%s body missing %q:\n%s", tc.path, want, rec.Body.String()) + } + } + } +} + +func TestOpenWebRepositoryUsesBrokerFromRepoConfig(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.broker", "https://broker.example.test"}, + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.provider", "gcs"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + repo, apiRepo, closeStore, cfg, err := openWebRepository(context.Background(), config{}, false) + if err != nil { + t.Fatal(err) + } + defer closeStore() + if _, ok := repo.store.(*localGitStore); !ok { + t.Fatalf("seed store = %T, want *localGitStore", repo.store) + } + store, ok := apiRepo.store.(*brokerGitStore) + if !ok { + t.Fatalf("api store = %T, want *brokerGitStore", apiRepo.store) + } + if store.brokerURL != "https://broker.example.test" || cfg.brokerURL != "https://broker.example.test" || cfg.logicalRepo != "team/app.git" { + t.Fatalf("store=%#v cfg=%#v", store, cfg) + } +} + +func TestOpenWebRepositoryLocalBypassesBroker(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.broker", "https://broker.example.test"}, + {"config", "bucketgit.logicalRepo", "team/app.git"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + repo, apiRepo, closeStore, _, err := openWebRepository(context.Background(), config{}, true) + if err != nil { + t.Fatal(err) + } + defer closeStore() + if _, ok := repo.store.(*localGitStore); !ok { + t.Fatalf("store = %T, want *localGitStore", repo.store) + } + if apiRepo != repo { + t.Fatalf("api repo should be local repo in --local mode") + } +} + +func TestWebClonePanelShowsBrokerCloneCommand(t *testing.T) { + server := &webServer{cfg: config{ + brokerURL: "https://broker.example.test/", + logicalRepo: "team/app.git", + origin: "git@git.bucketgit.com:team/app.git", + }} + html := server.clonePanelHTML() + if !strings.Contains(html, "team/app.git") { + t.Fatalf("clone panel missing repo: %s", html) + } + if !strings.Contains(html, "bgit clone https://broker.example.test/team/app.git") { + t.Fatalf("clone panel missing broker clone command: %s", html) + } + if !strings.Contains(html, "git@git.bucketgit.com:team/app.git") { + t.Fatalf("clone panel missing ssh origin: %s", html) + } +} + +func TestWebRepoHeaderUsesShortTitleAndBrokerLocationBadge(t *testing.T) { + cfg := config{ + brokerURL: "https://broker.example.test/", + logicalRepo: "team/app.git", + } + title := webRepoTitle(cfg) + if title != "team/app.git" { + t.Fatalf("title = %q", title) + } + server := &webServer{cfg: cfg, title: title} + if badge := server.repoLocationBadge(); badge != "broker.example.test/team/app.git" { + t.Fatalf("badge = %q", badge) + } + header := server.headerHTML("refs/heads/main", "") + if strings.Contains(header, "bucketgit repository") { + t.Fatalf("header should not include repository label: %s", header) + } + if !strings.Contains(header, `data-theme-toggle`) { + t.Fatalf("header missing theme toggle: %s", header) + } +} + +func TestWebHandlerCanRenderSeedThenRemote(t *testing.T) { + localRoot := t.TempDir() + remoteRoot := t.TempDir() + for _, root := range []string{localRoot, remoteRoot} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + localSource := filepath.Join(localRoot, "source") + remoteSource := filepath.Join(remoteRoot, "source") + localBare := filepath.Join(localRoot, "repo.git") + remoteBare := filepath.Join(remoteRoot, "repo.git") + for _, bare := range []string{localBare, remoteBare} { + if _, err := runGit("", "init", "--bare", bare); err != nil { + t.Fatal(err) + } + } + for _, item := range []struct { + worktree string + bare string + text string + message string + }{ + {localSource, localBare, "# Local\n", "Local commit"}, + {remoteSource, remoteBare, "# Remote\n", "Remote commit"}, + } { + if _, err := runGit("", "init", "--initial-branch", "main", item.worktree); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(item.worktree, "README.md"), []byte(item.text), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(item.worktree, "add", "README.md"); err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "GIT_AUTHOR_NAME=Ada", "GIT_AUTHOR_EMAIL=ada@example.com", "GIT_COMMITTER_NAME=Ada", "GIT_COMMITTER_EMAIL=ada@example.com") + if _, err := runGitEnv(item.worktree, env, "commit", "-m", item.message); err != nil { + t.Fatal(err) + } + if _, err := runGit(item.worktree, "push", item.bare, "HEAD:refs/heads/main"); err != nil { + t.Fatal(err) + } + } + seed := newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: localBare}) + remote := newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: remoteBare}) + handler := newWebHandlerWithAPI(seed, remote, config{branch: "main", origin: "gs://bucket/repo.git"}) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), "# Local") || strings.Contains(rec.Body.String(), "# Remote") { + t.Fatalf("seed body = %s", rec.Body.String()) + } + req = httptest.NewRequest(http.MethodGet, "/?_remote=1", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), "# Remote") || strings.Contains(rec.Body.String(), "# Local") { + t.Fatalf("remote body = %s", rec.Body.String()) + } + req = httptest.NewRequest(http.MethodGet, "/api/tree", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), `"subject":"Remote commit"`) { + t.Fatalf("api body = %s", rec.Body.String()) + } +} + +func TestWebPullRequestCacheRendersPRTabAndPage(t *testing.T) { + bare := filepath.Join(t.TempDir(), "repo.git") + if _, err := runGit("", "init", "--bare", bare); err != nil { + t.Fatal(err) + } + handler := newWebHandlerWithAPI( + newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: bare}), + nil, + config{branch: "main", brokerURL: "https://broker.example.test", logicalRepo: "team/app.git", provider: "gcs"}, + ) + if err := handler.writePullRequestCache([]brokerPullRequest{{ + ID: 7, + Title: "Add docs", + Source: "refs/heads/docs", + Target: "refs/heads/main", + Status: "open", + Approvals: 1, + }}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/prs", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Add docs") || !strings.Contains(rec.Body.String(), `data-pr-tab`) { + t.Fatalf("body = %s", rec.Body.String()) + } + data, err := os.ReadFile(filepath.Join(bare, "bucketgit", "cache", "prs.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"title": "Add docs"`) { + t.Fatalf("cache = %s", string(data)) + } +} + func TestParseWebArgs(t *testing.T) { opts, err := parseWebArgs([]string{"--local", "--addr", "0.0.0.0", "--port", "9000"}) if err != nil { @@ -2811,15 +3362,17 @@ func TestBrokerGitStoreReadsAndListsThroughBroker(t *testing.T) { paths = append(paths, r.URL.Path) w.Header().Set("content-type", "application/json") switch r.URL.Path { - case "/objects/read": - var req brokerObjectRequest + case "/objects/capability": + var req brokerObjectCapabilityRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatal(err) } - if req.Repo.Bucket != "bucket" || req.Path != "objects/aa/bb" { + if req.Repo.Bucket != "bucket" || req.Path != "objects/aa/bb" || req.Operation != "read" { t.Fatalf("read req = %#v", req) } - _, _ = fmt.Fprintf(w, `{"data":%q}`, base64.StdEncoding.EncodeToString([]byte("object data"))) + _, _ = fmt.Fprintf(w, `{"provider":"gcs","mode":"signed_url","method":"GET","url":%q}`, "http://"+r.Host+"/object") + case "/object": + _, _ = w.Write([]byte("object data")) case "/objects/list": var req brokerObjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -2850,7 +3403,7 @@ func TestBrokerGitStoreReadsAndListsThroughBroker(t *testing.T) { if strings.Join(listed, ",") != "refs/heads/main" { t.Fatalf("listed = %#v", listed) } - if strings.Join(paths, ",") != "/objects/read,/objects/list" { + if strings.Join(paths, ",") != "/objects/capability,/object,/objects/list" { t.Fatalf("paths = %#v", paths) } } diff --git a/native_git.go b/native_git.go index d0b3015..2abb1e3 100644 --- a/native_git.go +++ b/native_git.go @@ -284,6 +284,9 @@ func (r *nativeGitRepo) initWorktree(ctx context.Context, args []string, stdout if err := setGitOrigin(absTarget, originForConfig(cfg)); err != nil { return err } + if err := setGitBranchTracking(absTarget, cfg.branch, "origin"); err != nil { + return err + } cloneRepo := *r cloneRepo.cfg = cfg if err := cloneRepo.fetchIntoWorktree(ctx, absTarget, true, io.Discard); err != nil { @@ -337,30 +340,39 @@ func (r *nativeGitRepo) fetchIntoWorktree(ctx context.Context, worktree string, names = append(names, name) } sort.Strings(names) + var updates []string for _, name := range names { hash := refs[name] switch { case strings.HasPrefix(name, "refs/heads/"): localRef := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(name, "refs/heads/"))) + oldHash := readRefFile(localRef) if err := writeRefFile(localRef, hash); err != nil { return err } + short := strings.TrimPrefix(name, "refs/heads/") + switch { + case oldHash == "": + updates = append(updates, fmt.Sprintf(" * [new branch] %s -> bucketgit/%s", short, short)) + case oldHash != hash: + updates = append(updates, fmt.Sprintf(" %s..%s %s -> bucketgit/%s", shortHash(oldHash), shortHash(hash), short, short)) + } case tags && strings.HasPrefix(name, "refs/tags/"): localRef := filepath.Join(gitDir, filepath.FromSlash(name)) + oldHash := readRefFile(localRef) if err := writeRefFile(localRef, hash); err != nil { return err } + if oldHash == "" { + short := strings.TrimPrefix(name, "refs/tags/") + updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", short, short)) + } } } - fmt.Fprintf(stdout, "From %s\n", originForConfig(r.cfg)) - for _, name := range names { - switch { - case strings.HasPrefix(name, "refs/heads/"): - short := strings.TrimPrefix(name, "refs/heads/") - fmt.Fprintf(stdout, " * [new branch] %s -> bucketgit/%s\n", short, short) - case tags && strings.HasPrefix(name, "refs/tags/"): - short := strings.TrimPrefix(name, "refs/tags/") - fmt.Fprintf(stdout, " * [new tag] %s -> %s\n", short, short) + if len(updates) > 0 { + fmt.Fprintf(stdout, "From %s\n", originForConfig(r.cfg)) + for _, update := range updates { + fmt.Fprintln(stdout, update) } } return nil @@ -388,7 +400,7 @@ func (r *nativeGitRepo) pull(ctx context.Context, args []string, stdout io.Write if *rebase { return unsupportedCommand("rebase") } - if err := r.fetchIntoWorktree(ctx, worktree, true, io.Discard); err != nil { + if err := r.fetchIntoWorktree(ctx, worktree, true, stdout); err != nil { return err } localRepo, err := openLocalRepository(worktree) @@ -427,6 +439,18 @@ func (r *nativeGitRepo) pull(ctx context.Context, args []string, stdout io.Write return nil } +func readRefFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + hash := strings.TrimSpace(string(data)) + if !isHexHash(hash) { + return "" + } + return hash +} + func (r *nativeGitRepo) push(ctx context.Context, args []string, stdout io.Writer) error { opts, err := parsePushArgs(args) if err != nil { @@ -448,6 +472,10 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err != nil { return err } + localRepo, err := openLocalRepository(worktree) + if err != nil { + return err + } if err := uploadLocalObjects(ctx, store, gitDir); err != nil { return err } @@ -476,6 +504,14 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err := updateRef(normalized, firstNonEmpty(refs[normalized], zeroObjectID()), zeroObjectID()); err != nil { return err } + if err := updateLocalRemoteTrackingRef(gitDir, normalized, zeroObjectID()); err != nil { + return err + } + if strings.HasPrefix(normalized, "refs/heads/") { + if err := unsetGitBranchTracking(worktree, strings.TrimPrefix(normalized, "refs/heads/")); err != nil { + return err + } + } } fmt.Fprintf(stdout, "To %s\n", originForConfig(r.cfg)) for _, ref := range opts.refs { @@ -489,11 +525,21 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err != nil { return err } - ref := branchRef(r.cfg.branch) - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { - return err + branch := firstNonEmpty(localRepo.currentBranch(), r.cfg.branch, defaultBranch) + ref := branchRef(branch) + oldHash := pushOldHash(gitDir, refs, ref) + if oldHash != hash { + if err := updateRef(ref, oldHash, hash); err != nil { + return err + } + if err := updateLocalRemoteTrackingRef(gitDir, ref, hash); err != nil { + return err + } + if err := setGitBranchTrackingIfOrigin(worktree, branch); err != nil { + return err + } + updates = append(updates, pushUpdateLine(oldHash, hash, branch, branch)) } - updates = append(updates, fmt.Sprintf(" * [new branch] %s -> %s", r.cfg.branch, r.cfg.branch)) } else { for _, refspec := range opts.refs { src, dst, ok := strings.Cut(refspec, ":") @@ -506,10 +552,22 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return err } ref := normalizeDestinationRef(dst) - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { + oldHash := pushOldHash(gitDir, refs, ref) + if oldHash == hash { + continue + } + if err := updateRef(ref, oldHash, hash); err != nil { + return err + } + if err := updateLocalRemoteTrackingRef(gitDir, ref, hash); err != nil { return err } - updates = append(updates, fmt.Sprintf(" * [new branch] %s -> %s", shortRefName(src), shortRefName(normalizeDestinationRef(dst)))) + if strings.HasPrefix(ref, "refs/heads/") { + if err := setGitBranchTrackingIfOrigin(worktree, strings.TrimPrefix(ref, "refs/heads/")); err != nil { + return err + } + } + updates = append(updates, pushUpdateLine(oldHash, hash, shortRefName(src), shortRefName(normalizeDestinationRef(dst)))) } } if opts.tags { @@ -518,12 +576,24 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return err } for ref, hash := range tags { - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { + oldHash := firstNonEmpty(refs[ref], zeroObjectID()) + if oldHash == hash { + continue + } + if err := updateRef(ref, oldHash, hash); err != nil { return err } - updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", shortRefName(ref), shortRefName(ref))) + if oldHash == zeroObjectID() { + updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", shortRefName(ref), shortRefName(ref))) + } else { + updates = append(updates, fmt.Sprintf(" %s..%s %s -> %s", shortHash(oldHash), shortHash(hash), shortRefName(ref), shortRefName(ref))) + } } } + if len(updates) == 0 { + fmt.Fprintln(stdout, "Everything up-to-date") + return nil + } fmt.Fprintf(stdout, "To %s\n", originForConfig(r.cfg)) for _, line := range updates { fmt.Fprintln(stdout, line) @@ -531,6 +601,53 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return nil } +func pushOldHash(gitDir string, refs map[string]string, ref string) string { + if hash := refs[ref]; hash != "" { + return hash + } + if hash := localRemoteTrackingHash(gitDir, ref); hash != "" { + return hash + } + return zeroObjectID() +} + +func pushUpdateLine(oldHash, newHash, src, dst string) string { + if oldHash == zeroObjectID() { + return fmt.Sprintf(" * [new branch] %s -> %s", src, dst) + } + return fmt.Sprintf(" %s..%s %s -> %s", shortHash(oldHash), shortHash(newHash), src, dst) +} + +func localRemoteTrackingHash(gitDir, ref string) string { + if !strings.HasPrefix(ref, "refs/heads/") { + return "" + } + path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + hash := strings.TrimSpace(string(data)) + if !isHexHash(hash) { + return "" + } + return hash +} + +func updateLocalRemoteTrackingRef(gitDir, ref, hash string) error { + if !strings.HasPrefix(ref, "refs/heads/") { + return nil + } + path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) + if hash == zeroObjectID() { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + return writeRefFile(path, hash) +} + func (r *nativeGitRepo) putFile(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer) error { opts, err := parsePutArgs(args) if err != nil { diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..465141c --- /dev/null +++ b/setup.go @@ -0,0 +1,3065 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "golang.org/x/term" +) + +const setupProbeTimeout = 2 * time.Second +const setupDialogProfilesPerProvider = 10 +const setupRegionDialogItemsPerPage = 10 + +var errSetupBack = errors.New("setup back") +var setupProfileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@+-]+$`) +var setupAWSAccessKeyPattern = regexp.MustCompile(`^(A3T[A-Z0-9]|AKIA|ASIA)[A-Z0-9]{16}$`) +var setupAWSRegionPattern = regexp.MustCompile(`^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$`) + +type setupOptions struct { + yes bool + provider string + profiles []string + configPath string + region string + keys []string + noAgent bool +} + +type setupProfile struct { + Provider string + Name string + Active bool + Existing bool + Account string + ProjectID string + AccountID string + ARN string + Region string + ConfiguredRegions []string +} + +type setupSSHKey struct { + PublicKey string + Source string + Comment string +} + +type setupSelection struct { + Profiles []setupProfile + Keys []setupSSHKey + IdentityName string + IdentityEmail string + Action string + CreateProvider string + CreateName string + CreateAccessKey string + CreateSecretKey string + CreateRegion string + DefaultCreate string + DefaultCreateByProvider map[string]string +} + +type brokerOwnerRequest struct { + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` +} + +func setupCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) >= 2 && args[0] == "profile" && args[1] == "create" { + return setupProfileCreateCommand(args[2:], stdin, stdout) + } + opts, err := parseSetupArgs(args) + if err != nil { + return err + } + if len(opts.profiles) == 0 && base.gcloudConfigurationExplicit && strings.TrimSpace(base.gcloudConfiguration) != "" { + opts.profiles = append(opts.profiles, strings.TrimSpace(base.gcloudConfiguration)) + } + interactiveReader := bufio.NewReader(stdin) + path := opts.configPath + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return err + } + } + fmt.Fprintln(stdout, "discovering cloud profiles...") + profiles, err := discoverSetupProfiles(ctx) + if err != nil { + return err + } + global, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + global = globalConfig{Version: globalConfigVersion} + } + if global.Version == 0 { + global.Version = globalConfigVersion + } + profiles = markConfiguredSetupProfiles(profiles, global) + profiles = filterSetupProfiles(profiles, opts.provider, opts.profiles, opts.region) + if len(profiles) == 0 { + if opts.yes { + return errors.New("no cloud profiles found; install/configure gcloud or AWS CLI profiles first") + } + if len(setupAvailableCreateProviders()) == 0 { + return errors.New("no cloud profiles found; install/configure gcloud or AWS CLI profiles first") + } + } + keys, err := discoverSetupSSHKeys(setupSSHKeyOptions{Paths: opts.keys, NoAgent: opts.noAgent}) + if err != nil { + return err + } +selectAgain: + for { + selection := setupSelection{ + Profiles: profiles, + Keys: keys, + IdentityName: global.Identity.Name, + IdentityEmail: global.Identity.Email, + DefaultCreate: firstSetupRequestedProfile(opts), + DefaultCreateByProvider: setupCreateProfileDefaults(profiles, opts), + } + if !opts.yes { + selected, err := runSetupDialogWithRaw(interactiveReader, stdin, stdout, selection) + if err != nil { + return err + } + selection = selected + } + if selection.Action == "create-profile" { + if err := setupInteractiveCreateProfile(selection, stdout); err != nil { + return err + } + fmt.Fprintln(stdout, "rediscovering cloud profiles...") + profiles, err = discoverSetupProfiles(ctx) + if err != nil { + return err + } + profiles = markConfiguredSetupProfiles(profiles, global) + profiles = filterSetupProfiles(profiles, opts.provider, opts.profiles, opts.region) + continue selectAgain + } + identityChanged := setupSelectionIdentityChanged(selection, global) + if len(selection.Profiles) == 0 && !identityChanged { + return errors.New("setup requires at least one selected cloud profile") + } + if strings.TrimSpace(selection.IdentityEmail) != "" && !identityEmailPattern.MatchString(strings.TrimSpace(selection.IdentityEmail)) { + return fmt.Errorf("email address %q looks invalid", strings.TrimSpace(selection.IdentityEmail)) + } + if strings.TrimSpace(selection.IdentityName) != "" || strings.TrimSpace(selection.IdentityEmail) != "" { + global.Identity = globalIdentityConfig{ + Name: strings.TrimSpace(selection.IdentityName), + Email: strings.TrimSpace(selection.IdentityEmail), + } + } + if len(selection.Profiles) == 0 { + if err := writeGlobalConfig(path, global); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) + return nil + } + var publicKeys []string + for _, key := range selection.Keys { + publicKeys = append(publicKeys, key.PublicKey) + } + publicKeys = uniqueStrings(publicKeys) + now := time.Now().UTC().Format(time.RFC3339) + for _, profile := range selection.Profiles { + if profile.Provider == "s3" && (profile.AccountID == "" || profile.ARN == "") { + accountID, arn := awsCallerIdentity(ctx, profile.Name) + profile.AccountID = firstNonEmpty(profile.AccountID, accountID) + profile.ARN = firstNonEmpty(profile.ARN, arn) + } + cfg := base + cfg.provider = profile.Provider + cfg.gcloudConfiguration = profile.Name + cfg.gcloudConfigurationExplicit = profile.Name != "" + if profile.Provider == "gcs" { + if err := requireSetupCLI("gcloud", "GCP"); err != nil { + return err + } + if err := ensureGcloudSetupAuth(ctx, cfg, !opts.yes, stdin, stdout); err != nil { + return err + } + if err := ensureGcloudSetupProjectAccess(ctx, cfg, !opts.yes, stdin, stdout); err != nil { + return err + } + if project := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project"); project != "" { + profile.ProjectID = project + } + if err := ensureGcloudSetupBilling(ctx, cfg, profile.ProjectID, !opts.yes, stdin, stdout); err != nil { + return err + } + regions, err := resolveGCPSetupRegionsWithRaw(profile, opts.region, !opts.yes, interactiveReader, stdin, stdout) + if err != nil { + if errors.Is(err, errSetupBack) && !opts.yes { + continue selectAgain + } + return err + } + if len(regions) == 0 { + return fmt.Errorf("GCP profile %s requires at least one selected region", profile.Name) + } + for _, region := range regions { + profile.Region = region + if err := setupProvisionSelectedProfile(base, path, now, profile, opts, publicKeys, &global, stdout); err != nil { + return err + } + } + continue + } else if profile.Provider == "s3" { + if err := requireSetupCLI("aws", "AWS"); err != nil { + return err + } + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, opts.region, !opts.yes, interactiveReader, stdin, stdout) + if err != nil { + if errors.Is(err, errSetupBack) && !opts.yes { + continue selectAgain + } + return err + } + if len(regions) == 0 { + return fmt.Errorf("AWS profile %s requires at least one selected region", profile.Name) + } + for _, region := range regions { + profile.Region = region + if err := setupProvisionSelectedProfile(base, path, now, profile, opts, publicKeys, &global, stdout); err != nil { + return err + } + } + continue + } + } + if err := writeGlobalConfig(path, global); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Next steps:") + fmt.Fprintln(stdout, " bgit init") + fmt.Fprintln(stdout, " bgit init --noninteractive --repo my-repo --profile PROFILE") + fmt.Fprintln(stdout, " git push -u origin main") + return nil + } +} + +func setupProfileCreateCommand(args []string, stdin io.Reader, stdout io.Writer) error { + provider := "" + var rest []string + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--provider": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + provider = normalizeSetupProvider(value) + if provider == "" { + return fmt.Errorf("unsupported setup profile provider %q", value) + } + case "gcp", "gcs", "aws", "s3": + if provider == "" { + provider = normalizeSetupProvider(arg) + continue + } + rest = append(rest, arg) + default: + rest = append(rest, arg) + } + } + if provider == "" { + return errors.New("usage: bgit setup profile create --provider gcp|aws NAME") + } + switch provider { + case "gcs": + return createGcloudProfileCommand(rest, stdin, stdout) + case "s3": + return createAWSProfileCommand(rest, stdin, stdout) + default: + return errors.New("usage: bgit setup profile create --provider gcp|aws NAME") + } +} + +func setupInteractiveCreateProfile(selection setupSelection, stdout io.Writer) error { + provider := selection.CreateProvider + name := strings.TrimSpace(selection.CreateName) + if name == "" { + name = "default" + } + switch provider { + case "gcs": + if _, err := exec.LookPath("gcloud"); err != nil { + return errors.New("gcloud is not installed") + } + return createGcloudProfileCommand([]string{"--yes", name}, strings.NewReader(""), stdout) + case "s3": + if _, err := exec.LookPath("aws"); err != nil { + return errors.New("AWS CLI is not installed") + } + return createAWSProfileConfigured(name, selection.CreateAccessKey, selection.CreateSecretKey, selection.CreateRegion, stdout) + default: + return errors.New("unknown setup profile provider") + } +} + +func setupSelectionIdentityChanged(selection setupSelection, cfg globalConfig) bool { + return strings.TrimSpace(selection.IdentityName) != strings.TrimSpace(cfg.Identity.Name) || + strings.TrimSpace(selection.IdentityEmail) != strings.TrimSpace(cfg.Identity.Email) +} + +func firstSetupRequestedProfile(opts setupOptions) string { + if len(opts.profiles) > 0 { + return opts.profiles[0] + } + return "" +} + +func setupCreateProfileDefaults(profiles []setupProfile, opts setupOptions) map[string]string { + defaults := map[string]string{} + if requested := firstSetupRequestedProfile(opts); requested != "" { + if opts.provider == "" || opts.provider == "gcs" { + defaults["gcs"] = requested + } + if opts.provider == "" || opts.provider == "s3" { + defaults["s3"] = requested + } + return defaults + } + hasDefault := map[string]bool{} + for _, profile := range profiles { + if profile.Name == "default" { + hasDefault[profile.Provider] = true + } + } + if !hasDefault["gcs"] { + defaults["gcs"] = "default" + } + if !hasDefault["s3"] { + defaults["s3"] = "default" + } + return defaults +} + +func setupProvisionSelectedProfile(base config, path, now string, profile setupProfile, opts setupOptions, publicKeys []string, global *globalConfig, stdout io.Writer) error { + _ = path + cfg := base + cfg.provider = profile.Provider + cfg.gcloudConfiguration = profile.Name + cfg.gcloudConfigurationExplicit = profile.Name != "" + brokerURL, err := provisionBrokerURL(cfg, sshSetupOptions{region: firstNonEmpty(opts.region, profile.Region)}, stdout) + if err != nil { + return err + } + if len(publicKeys) > 0 { + if err := brokerUpsertOwners(brokerURL, publicKeys); err != nil { + return err + } + fmt.Fprintf(stdout, "imported %d owner key(s) into broker %s\n", len(publicKeys), brokerURL) + } + switch profile.Provider { + case "gcs": + serviceAccount := "" + if strings.TrimSpace(profile.ProjectID) != "" { + serviceAccount = gcpBrokerServiceAccountEmail(profile.ProjectID) + } + *global = upsertGlobalGCPProfile(*global, globalGCPProfile{ + Name: profile.Name, + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: serviceAccount, + Regions: []globalProfileRegion{{ + Name: profile.Region, + BrokerURL: brokerURL, + BrokerVersion: brokerVersion, + LastSetupAt: now, + }}, + }) + case "s3": + *global = upsertGlobalAWSProfile(*global, globalAWSProfile{ + Name: profile.Name, + AccountID: profile.AccountID, + ARN: profile.ARN, + Regions: []globalProfileRegion{{ + Name: profile.Region, + BrokerURL: brokerURL, + BrokerVersion: brokerVersion, + LastSetupAt: now, + }}, + }) + } + return nil +} + +func offerSetupProfileBootstrap(opts setupOptions, reader *bufio.Reader, stdout io.Writer) error { + provider := opts.provider + if provider == "" { + provider = promptSetupProvider(reader, stdout) + } + switch provider { + case "gcs": + if _, err := exec.LookPath("gcloud"); err != nil { + return errors.New("gcloud is not installed") + } + fmt.Fprint(stdout, "No usable gcloud profiles found. Create one now? [y/N] ") + if !readSetupYes(reader) { + return errors.New("setup requires a cloud profile") + } + fmt.Fprint(stdout, "GCP profile name [default]: ") + name := readSetupLine(reader) + if name == "" && len(opts.profiles) > 0 { + name = opts.profiles[0] + } + if name == "" { + name = "default" + } + if err := runGcloudProfileCommand(stdout, "config", "configurations", "create", name); err != nil { + return err + } + return runGcloudProfileCommand(stdout, "auth", "login", "--configuration", name) + case "s3": + if _, err := exec.LookPath("aws"); err != nil { + return errors.New("AWS CLI is not installed") + } + fmt.Fprint(stdout, "No usable AWS profiles found. Run aws configure now? [y/N] ") + if !readSetupYes(reader) { + return errors.New("setup requires a cloud profile") + } + fmt.Fprint(stdout, "AWS profile name [default]: ") + name := readSetupLine(reader) + if name == "" && len(opts.profiles) > 0 { + name = opts.profiles[0] + } + if name == "" { + name = "default" + } + return runAWSProfileCommand(stdout, "configure", "--profile", name) + default: + return errors.New("setup requires --provider gcp or --provider aws to create a profile") + } +} + +func promptSetupProvider(reader *bufio.Reader, stdout io.Writer) string { + gcloudOK := false + awsOK := false + if _, err := exec.LookPath("gcloud"); err == nil { + gcloudOK = true + } + if _, err := exec.LookPath("aws"); err == nil { + awsOK = true + } + if gcloudOK && !awsOK { + return "gcs" + } + if awsOK && !gcloudOK { + return "s3" + } + if !gcloudOK && !awsOK { + return "" + } + fmt.Fprint(stdout, "Create profile for provider [gcp/aws]: ") + switch strings.ToLower(readSetupLine(reader)) { + case "gcp", "gcs": + return "gcs" + case "aws", "s3": + return "s3" + default: + return "" + } +} + +func readSetupYes(reader *bufio.Reader) bool { + answer := strings.ToLower(readSetupLine(reader)) + return answer == "y" || answer == "yes" +} + +func readSetupLine(reader *bufio.Reader) string { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "" + } + return strings.TrimSpace(line) +} + +type gcloudProjectOption struct { + ID string + Name string +} + +type gcloudBillingAccountOption struct { + Name string + DisplayName string + Open bool +} + +var defaultGCPSetupRegions = []string{ + "us-central1", + "us-east1", + "us-east4", + "us-west1", + "us-west2", + "us-west3", + "us-west4", + "northamerica-northeast1", + "southamerica-east1", + "europe-west1", + "europe-west2", + "europe-west3", + "europe-west4", + "europe-west6", + "europe-central2", + "asia-east1", + "asia-east2", + "asia-northeast1", + "asia-northeast2", + "asia-northeast3", + "asia-south1", + "asia-southeast1", + "asia-southeast2", + "australia-southeast1", +} + +var awsSetupRegions = []string{ + "us-east-1", + "eu-west-1", + "eu-central-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-south-1", + "sa-east-1", + "ca-central-1", + "af-south-1", + "ap-east-1", + "ap-east-2", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-2", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ca-west-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "mx-central-1", + "us-east-2", + "us-west-1", +} + +func parseSetupArgs(args []string) (setupOptions, error) { + var opts setupOptions + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--yes", "-y": + opts.yes = true + case "--provider": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.provider = normalizeSetupProvider(value) + if opts.provider == "" { + return opts, fmt.Errorf("unsupported setup provider %q", value) + } + case "--profile": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.profiles = append(opts.profiles, value) + case "--config": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.configPath = expandHome(value) + case "--region": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.region = value + case "--key": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.keys = append(opts.keys, value) + case "--no-agent": + opts.noAgent = true + default: + return opts, fmt.Errorf("unsupported setup option %s", arg) + } + } + return opts, nil +} + +func discoverSetupProfiles(ctx context.Context) ([]setupProfile, error) { + var profiles []setupProfile + gcp, err := discoverGCPSetupProfiles(ctx) + if err != nil { + return nil, err + } + profiles = append(profiles, gcp...) + aws, err := discoverAWSSetupProfiles(ctx) + if err != nil { + return nil, err + } + profiles = append(profiles, aws...) + return profiles, nil +} + +func discoverGCPSetupProfiles(ctx context.Context) ([]setupProfile, error) { + if _, err := exec.LookPath("gcloud"); err != nil { + return nil, nil + } + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + out, err := exec.CommandContext(probeCtx, "gcloud", "config", "configurations", "list", "--format=value(name,is_active)").Output() + if err != nil { + return nil, nil + } + var profiles []setupProfile + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 { + continue + } + name := fields[0] + active := len(fields) > 1 && strings.EqualFold(fields[1], "true") + profile := setupProfile{Provider: "gcs", Name: name, Active: active} + profile.Account = gcloudConfigValue(ctx, name, "account") + profile.ProjectID = gcloudConfigValue(ctx, name, "project") + profile.Region = firstNonEmpty(gcloudConfigValue(ctx, name, "run/region"), gcloudConfigValue(ctx, name, "functions/region"), "us-central1") + profiles = append(profiles, profile) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return profiles, nil +} + +func requireSetupCLI(binary, provider string) error { + if _, err := exec.LookPath(binary); err != nil { + return fmt.Errorf("%s CLI is not installed; install `%s` or deselect the %s profile", provider, binary, provider) + } + return nil +} + +func gcloudConfigValue(ctx context.Context, profile, key string) string { + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "--configuration", profile, "config", "get-value", key, "--quiet") + out, err := cmd.Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value +} + +func discoverAWSSetupProfiles(ctx context.Context) ([]setupProfile, error) { + _ = ctx + names := map[string]struct{}{} + for _, name := range awsProfilesFromFiles() { + names[name] = struct{}{} + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + var profiles []setupProfile + for _, name := range sorted { + profile := setupProfile{Provider: "s3", Name: name, Region: configuredAWSProfileRegion(name)} + profiles = append(profiles, profile) + } + return profiles, nil +} + +func awsProfilesFromFiles() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + names := map[string]struct{}{} + for _, path := range []string{filepath.Join(home, ".aws", "config"), filepath.Join(home, ".aws", "credentials")} { + for _, name := range parseAWSProfileFile(path) { + names[name] = struct{}{} + } + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + return sorted +} + +func parseAWSProfileFile(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + names := map[string]struct{}{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "[") || !strings.HasSuffix(line, "]") { + continue + } + name := strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + name = strings.TrimSpace(strings.TrimPrefix(name, "profile ")) + if name != "" { + names[name] = struct{}{} + } + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + return sorted +} + +func configuredAWSProfileRegion(profile string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, path := range []string{filepath.Join(home, ".aws", "config"), filepath.Join(home, ".aws", "credentials")} { + if region := awsProfileFileValue(path, profile, "region"); region != "" { + return region + } + } + return "" +} + +func awsProfileFileValue(path, profile, key string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + section := "" + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + section = strings.TrimSpace(strings.TrimPrefix(section, "profile ")) + continue + } + if section != profile { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + return strings.TrimSpace(v) + } + return "" +} + +func awsCallerIdentity(ctx context.Context, profile string) (string, string) { + if _, err := exec.LookPath("aws"); err != nil { + return "", "" + } + args := []string{"sts", "get-caller-identity", "--output", "json"} + if profile != "" { + args = append(args, "--profile", profile) + } + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + out, err := exec.CommandContext(probeCtx, "aws", args...).Output() + if err != nil { + return "", "" + } + var resp struct { + Account string `json:"Account"` + ARN string `json:"Arn"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return "", "" + } + return resp.Account, resp.ARN +} + +func resolveGCPSetupRegion(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveGCPSetupRegionsWithRaw(profile, explicitRegion, interactive, stdin, stdin, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveGCPSetupRegionWithRaw(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveGCPSetupRegionsWithRaw(profile, explicitRegion, interactive, stdin, rawInput, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveGCPSetupRegionsWithRaw(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) ([]string, error) { + if strings.TrimSpace(explicitRegion) != "" { + return []string{strings.TrimSpace(explicitRegion)}, nil + } + defaultRegion := firstNonEmpty(strings.TrimSpace(profile.Region), "us-central1") + if !interactive { + return []string{defaultRegion}, nil + } + regions := gcpSetupRegions(defaultRegion) + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupRegionDialogWithRaw(reader, rawInput, stdout, "GCP", profile.Name, regions, setupRegionInitialSelection(profile)) +} + +func gcpSetupRegions(defaultRegion string) []string { + seen := map[string]struct{}{} + var regions []string + add := func(region string) { + region = strings.TrimSpace(region) + if region == "" { + return + } + if _, ok := seen[region]; ok { + return + } + seen[region] = struct{}{} + regions = append(regions, region) + } + add(defaultRegion) + for _, region := range defaultGCPSetupRegions { + add(region) + } + return regions +} + +func resolveAWSSetupRegion(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, explicitRegion, interactive, stdin, stdin, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveAWSSetupRegionWithRaw(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, explicitRegion, interactive, stdin, rawInput, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveAWSSetupRegionsWithRaw(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) ([]string, error) { + if err := requireSetupCLI("aws", "AWS"); err != nil { + return nil, err + } + if strings.TrimSpace(explicitRegion) != "" { + return []string{strings.TrimSpace(explicitRegion)}, nil + } + if !interactive { + if strings.TrimSpace(profile.Region) != "" { + return []string{strings.TrimSpace(profile.Region)}, nil + } + return nil, fmt.Errorf("AWS profile %s has no configured region; pass --region REGION or set aws_region/region in ~/.aws/config", profile.Name) + } + _ = ctx + if len(awsSetupRegions) == 0 { + return nil, fmt.Errorf("AWS profile %s has no enabled regions visible; pass --region REGION", profile.Name) + } + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupRegionDialogWithRaw(reader, rawInput, stdout, "AWS", profile.Name, awsSetupRegions, setupRegionInitialSelection(profile)) +} + +func markConfiguredSetupProfiles(profiles []setupProfile, cfg globalConfig) []setupProfile { + configured := map[string][]string{} + for _, profile := range cfg.GCPProfiles { + if regions := configuredSetupProfileRegions(profile.Regions); len(regions) > 0 { + configured["gcs:"+profile.Name] = regions + } + } + for _, profile := range cfg.AWSProfiles { + if regions := configuredSetupProfileRegions(profile.Regions); len(regions) > 0 { + configured["s3:"+profile.Name] = regions + } + } + if len(configured) == 0 { + return profiles + } + var out []setupProfile + for _, profile := range profiles { + regions, ok := configured[profile.Provider+":"+profile.Name] + if !ok { + out = append(out, profile) + continue + } + for _, region := range regions { + next := profile + next.Existing = true + next.Region = region + next.ConfiguredRegions = append([]string{}, regions...) + out = append(out, next) + } + } + return out +} + +func configuredSetupProfileRegions(regions []globalProfileRegion) []string { + var out []string + for _, region := range regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + if name := strings.TrimSpace(region.Name); name != "" { + out = append(out, name) + } + } + if len(out) > 0 { + return uniqueStrings(out) + } + for _, region := range regions { + if name := strings.TrimSpace(region.Name); name != "" { + out = append(out, name) + } + } + return uniqueStrings(out) +} + +func setupRegionInitialSelection(profile setupProfile) []string { + if len(profile.ConfiguredRegions) > 0 { + return append([]string{}, profile.ConfiguredRegions...) + } + if profile.Existing && strings.TrimSpace(profile.Region) != "" { + return []string{strings.TrimSpace(profile.Region)} + } + return nil +} + +func filterSetupProfiles(profiles []setupProfile, provider string, names []string, explicitRegion string) []setupProfile { + nameSet := map[string]struct{}{} + for _, name := range names { + nameSet[name] = struct{}{} + } + var out []setupProfile + for _, profile := range profiles { + if provider != "" && profile.Provider != provider { + continue + } + if len(nameSet) > 0 { + if !setupProfileNameSelected(profile, nameSet) { + continue + } + } + if strings.TrimSpace(explicitRegion) != "" { + profile.Region = strings.TrimSpace(explicitRegion) + } + out = append(out, profile) + } + if strings.TrimSpace(explicitRegion) != "" { + out = dedupeSetupProfiles(out) + } + return out +} + +func setupProfileNameSelected(profile setupProfile, names map[string]struct{}) bool { + candidates := []string{ + profile.Name, + profile.Name + "." + profile.Region, + providerProfileName(profile.Provider) + ":" + profile.Name, + providerProfileName(profile.Provider) + ":" + profile.Name + "." + profile.Region, + providerProfileName(profile.Provider) + ":" + profile.Name + "/" + profile.Region, + } + for _, candidate := range candidates { + if _, ok := names[candidate]; ok { + return true + } + } + return false +} + +func dedupeSetupProfiles(profiles []setupProfile) []setupProfile { + seen := map[string]struct{}{} + var out []setupProfile + for _, profile := range profiles { + key := profile.Provider + ":" + profile.Name + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, profile) + } + return out +} + +func normalizeSetupProvider(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "gcp", "gcs", "google": + return "gcs" + case "aws", "s3": + return "s3" + default: + return "" + } +} + +type setupSSHKeyOptions struct { + Paths []string + NoAgent bool +} + +func discoverSetupSSHKeys(opts setupSSHKeyOptions) ([]setupSSHKey, error) { + var keys []setupSSHKey + for _, path := range opts.Paths { + data, err := os.ReadFile(expandHome(path)) + if err != nil { + return nil, err + } + for _, key := range parseSetupSSHKeys(string(data), path) { + keys = append(keys, key) + } + } + if !opts.NoAgent { + agentKeys, err := sshAgentPublicKeys() + if err == nil { + for _, key := range agentKeys { + keys = append(keys, setupSSHKey{PublicKey: key, Source: "ssh-agent", Comment: sshKeyComment(key)}) + } + } + } + fileKeys, err := discoverSSHKeyFiles() + if err != nil { + return nil, err + } + keys = append(keys, fileKeys...) + return dedupeSetupSSHKeys(keys), nil +} + +func discoverSSHKeyFiles() ([]setupSSHKey, error) { + var dirs []string + home, err := os.UserHomeDir() + if err == nil { + dirs = append(dirs, filepath.Join(home, ".ssh")) + if runtime.GOOS == "windows" { + dirs = append(dirs, filepath.Join(home, "ssh")) + } + } + var keys []setupSSHKey + for _, dir := range dirs { + matches, err := filepath.Glob(filepath.Join(dir, "*.pub")) + if err != nil { + return nil, err + } + sort.Strings(matches) + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + for _, key := range parseSetupSSHKeys(string(data), path) { + keys = append(keys, key) + } + } + } + return keys, nil +} + +func parseSetupSSHKeys(data, source string) []setupSSHKey { + var keys []setupSSHKey + for _, line := range splitPublicKeyLines(data) { + keys = append(keys, setupSSHKey{PublicKey: line, Source: source, Comment: sshKeyComment(line)}) + } + return keys +} + +func dedupeSetupSSHKeys(keys []setupSSHKey) []setupSSHKey { + seen := map[string]struct{}{} + var out []setupSSHKey + for _, key := range keys { + normalized := normalizeSSHPublicKey(key.PublicKey) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + key.PublicKey = normalized + out = append(out, key) + } + return out +} + +func normalizeSSHPublicKey(key string) string { + return strings.Join(strings.Fields(strings.TrimSpace(key))[:min(2, len(strings.Fields(strings.TrimSpace(key))))], " ") +} + +func sshKeyComment(key string) string { + fields := strings.Fields(strings.TrimSpace(key)) + if len(fields) <= 2 { + return "" + } + return strings.Join(fields[2:], " ") +} + +func ensureGcloudSetupAuth(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + if _, err := gcloudSetupAccessToken(ctx, cfg); err == nil { + return nil + } else if !gcloudAuthNeedsLogin(err.Error()) { + return err + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + loginCommand := fmt.Sprintf("gcloud auth login --configuration %s --no-launch-browser", profile) + if !interactive { + return fmt.Errorf("gcloud profile %s needs authentication; run `%s`", profile, loginCommand) + } + fmt.Fprintf(stdout, "gcloud profile %s needs authentication.\n", profile) + fmt.Fprintf(stdout, "Starting `%s`.\n", loginCommand) + fmt.Fprintln(stdout, "Open the URL printed by gcloud, finish the OAuth flow, then paste the code if prompted.") + cmd := exec.CommandContext(ctx, "gcloud", "auth", "login", "--configuration", profile, "--no-launch-browser") + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud auth login failed: %w", err) + } + if _, err := gcloudSetupAccessToken(ctx, cfg); err != nil { + return fmt.Errorf("gcloud profile %s is still not authenticated after login: %w", profile, err) + } + return nil +} + +func ensureGcloudSetupProjectAccess(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + project := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project") + if project == "" { + var err error + project, err = ensureGcloudSetupProjectSelected(ctx, cfg, interactive, stdin, stdout) + if err != nil { + return err + } + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return nil + } else if enabled, enableErr := maybeEnableGcloudSetupProjectAPIs(ctx, cfg, project, err, stdout); enableErr != nil { + return enableErr + } else if enabled { + return nil + } else if repaired, repairErr := maybeRepairGcloudQuotaProject(ctx, cfg, project, err, interactive, stdin, stdout); repairErr != nil { + return repairErr + } else if repaired { + return nil + } else if !gcloudAuthNeedsLogin(err.Error()) { + return err + } + if err := runGcloudSetupLogin(ctx, cfg, interactive, stdin, stdout); err != nil { + return err + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return nil + } else if enabled, enableErr := maybeEnableGcloudSetupProjectAPIs(ctx, cfg, project, err, stdout); enableErr != nil { + return enableErr + } else if enabled { + return nil + } else if repaired, repairErr := maybeRepairGcloudQuotaProject(ctx, cfg, project, err, interactive, stdin, stdout); repairErr != nil { + return repairErr + } else if !repaired { + return fmt.Errorf("gcloud profile %s still cannot access project %s after login: %w", cfg.gcloudConfiguration, project, err) + } + return nil +} + +func ensureGcloudSetupProjectSelected(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + if !interactive { + return "", errors.New("gcloud project is unset; run `gcloud config set project PROJECT --configuration " + profile + "`") + } + reader := bufio.NewReader(stdin) + account := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "account") + fmt.Fprintf(stdout, "gcloud profile %s", profile) + if account != "" { + fmt.Fprintf(stdout, " uses account %s", account) + } + fmt.Fprintln(stdout, " but has no project configured.") + projects, _ := listGcloudSetupProjects(ctx, cfg) + if len(projects) > 0 { + fmt.Fprintln(stdout, "Visible projects:") + for i, project := range projects { + label := project.ID + if strings.TrimSpace(project.Name) != "" { + label += " - " + project.Name + } + fmt.Fprintf(stdout, " %d. %s\n", i+1, label) + } + } + fmt.Fprintln(stdout, "Choose a project number, type an existing project ID, type `create`, or leave blank to cancel.") + fmt.Fprint(stdout, "Project: ") + choice := readSetupLine(reader) + if choice == "" { + return "", errors.New("setup requires a gcloud project") + } + projectID := "" + if n := parsePositiveInt(choice); n > 0 && n <= len(projects) { + projectID = projects[n-1].ID + } else if strings.EqualFold(choice, "create") { + created, err := createGcloudSetupProject(ctx, cfg, reader, stdout) + if err != nil { + return "", err + } + projectID = created + } else { + projectID = choice + } + if err := setGcloudSetupProject(ctx, cfg, projectID, stdout); err != nil { + return "", err + } + return projectID, nil +} + +func listGcloudSetupProjects(ctx context.Context, cfg config) ([]gcloudProjectOption, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "projects", "list", "--configuration", cfg.gcloudConfiguration, "--format=value(projectId,name)") + out, err := cmd.Output() + if err != nil { + return nil, err + } + var projects []gcloudProjectOption + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + project := gcloudProjectOption{ID: fields[0]} + if len(fields) > 1 { + project.Name = strings.Join(fields[1:], " ") + } + projects = append(projects, project) + } + return projects, scanner.Err() +} + +func createGcloudSetupProject(ctx context.Context, cfg config, reader *bufio.Reader, stdout io.Writer) (string, error) { + fmt.Fprint(stdout, "New project ID: ") + projectBase := readSetupLine(reader) + if projectBase == "" { + return "", errors.New("project ID is required") + } + fmt.Fprintf(stdout, "Project display name [%s]: ", projectBase) + name := readSetupLine(reader) + if name == "" { + name = projectBase + } + projectID, err := gcloudSetupInitialProjectID(projectBase) + if err != nil { + return "", err + } + if projectID != projectBase { + fmt.Fprintf(stdout, "Project ID %s is not a valid GCP project ID; trying %s.\n", projectBase, projectID) + } + if err := runGcloudSetupProjectCreate(ctx, cfg, projectID, name, stdout); err == nil { + return projectID, nil + } else if !gcloudSetupProjectIDAlreadyExists(err) { + return "", fmt.Errorf("create gcloud project %s: %w", projectID, err) + } + projectID, err = gcloudSetupProjectIDWithRandomSuffix(projectBase) + if err != nil { + return "", err + } + fmt.Fprintf(stdout, "Project ID %s is already in use; trying %s.\n", projectBase, projectID) + if err := runGcloudSetupProjectCreate(ctx, cfg, projectID, name, stdout); err != nil { + return "", fmt.Errorf("create gcloud project %s: %w", projectID, err) + } + return projectID, nil +} + +func runGcloudSetupProjectCreate(ctx context.Context, cfg config, projectID, name string, stdout io.Writer) error { + cmd := exec.CommandContext(ctx, "gcloud", "projects", "create", projectID, "--configuration", cfg.gcloudConfiguration, "--name", name) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + _, _ = stdout.Write(out) + } + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func gcloudSetupProjectIDAlreadyExists(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already in use") || strings.Contains(msg, "project id") && strings.Contains(msg, "in use") +} + +func gcloudSetupInitialProjectID(base string) (string, error) { + base = gcloudSetupProjectIDBase(base) + if base == "" { + return "", errors.New("project ID must contain at least one lowercase letter or digit") + } + if len(base) < 6 { + return gcloudSetupProjectIDWithRandomSuffix(base) + } + if len(base) > 30 { + base = strings.TrimRight(base[:30], "-") + } + if !gcloudSetupProjectIDValid(base) { + return "", fmt.Errorf("invalid project ID %q", base) + } + return base, nil +} + +func gcloudSetupProjectIDBase(base string) string { + base = strings.ToLower(strings.TrimSpace(base)) + var b strings.Builder + lastHyphen := false + for _, ch := range base { + valid := ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' + if valid { + b.WriteRune(ch) + lastHyphen = false + continue + } + if ch == '-' || ch == '_' || ch == ' ' || ch == '.' { + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + } + base = strings.Trim(b.String(), "-") + if base == "" { + return "" + } + if base[0] < 'a' || base[0] > 'z' { + base = "bgit-" + base + } + return base +} + +func gcloudSetupProjectIDValid(id string) bool { + if len(id) < 6 || len(id) > 30 { + return false + } + if id[0] < 'a' || id[0] > 'z' { + return false + } + last := id[len(id)-1] + if last == '-' { + return false + } + for _, ch := range id { + if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '-' { + continue + } + return false + } + return true +} + +func gcloudSetupProjectIDWithRandomSuffix(base string) (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(10000000)) + if err != nil { + return "", fmt.Errorf("generate project ID suffix: %w", err) + } + return gcloudSetupProjectIDWithSuffix(base, fmt.Sprintf("%07d", n.Int64())), nil +} + +func gcloudSetupProjectIDWithSuffix(base, suffix string) string { + base = gcloudSetupProjectIDBase(base) + if len(base) > 22 { + base = strings.TrimRight(base[:22], "-") + } + return base + "-" + suffix +} + +func setGcloudSetupProject(ctx context.Context, cfg config, projectID string, stdout io.Writer) error { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + for _, args := range [][]string{ + {"config", "set", "project", projectID, "--configuration", profile}, + {"config", "set", "billing/quota_project", projectID, "--configuration", profile}, + } { + cmd := exec.CommandContext(ctx, "gcloud", args...) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud %s failed: %w", strings.Join(args, " "), err) + } + } + return nil +} + +func ensureGcloudSetupBilling(ctx context.Context, cfg config, project string, interactive bool, stdin io.Reader, stdout io.Writer) error { + project = strings.TrimSpace(project) + if project == "" { + project = gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project") + } + if project == "" { + return errors.New("GCP project is not configured") + } + enabled, err := gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + if err != nil { + if gcloudProjectServiceDisabled(err.Error()) { + if enableErr := enableGcloudSetupProjectServices(ctx, cfg, project, []string{"cloudbilling.googleapis.com"}, stdout, "GCP Cloud Billing API"); enableErr != nil { + return enableErr + } + enabled, err = gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + } + if err != nil { + return fmt.Errorf("check GCP billing for project %s: %w", project, err) + } + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + if !interactive { + return fmt.Errorf("GCP project %s does not have billing enabled; run `gcloud billing projects link %s --billing-account BILLING_ACCOUNT --configuration %s`", project, project, profile) + } + accounts, err := listGcloudSetupBillingAccounts(ctx, cfg) + if err != nil { + return fmt.Errorf("list GCP billing accounts: %w", err) + } + var openAccounts []gcloudBillingAccountOption + for _, account := range accounts { + if account.Open { + openAccounts = append(openAccounts, account) + } + } + if len(openAccounts) == 0 { + return fmt.Errorf("GCP project %s does not have billing enabled and no open billing accounts are visible to profile %s", project, profile) + } + reader := bufio.NewReader(stdin) + fmt.Fprintf(stdout, "GCP project %s does not have billing enabled.\n", project) + fmt.Fprintln(stdout, "Visible billing accounts:") + for i, account := range openAccounts { + label := account.Name + if strings.TrimSpace(account.DisplayName) != "" { + label += " - " + account.DisplayName + } + fmt.Fprintf(stdout, " %d. %s\n", i+1, label) + } + fmt.Fprintln(stdout, "Choose a billing account number, type a billing account ID, or leave blank to cancel.") + fmt.Fprint(stdout, "Billing account: ") + choice := readSetupLine(reader) + if choice == "" { + return errors.New("setup requires billing to deploy the GCP broker") + } + billingAccount := choice + if n := parsePositiveInt(choice); n > 0 && n <= len(openAccounts) { + billingAccount = openAccounts[n-1].Name + } + if err := linkGcloudSetupBillingAccount(ctx, cfg, project, billingAccount, stdout); err != nil { + return err + } + if err := waitForGcloudSetupBilling(ctx, cfg, project); err != nil { + return err + } + return nil +} + +func gcloudSetupBillingEnabled(ctx context.Context, cfg config, project string) (bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, + "gcloud", "billing", "projects", "describe", project, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + "--format=value(billingEnabled)", + ) + out, err := cmd.CombinedOutput() + if err != nil { + if strings.EqualFold(strings.TrimSpace(string(out)), "false") { + return false, nil + } + return false, fmt.Errorf("%w\n%s", err, strings.TrimSpace(string(out))) + } + switch strings.ToLower(strings.TrimSpace(string(out))) { + case "true", "yes", "1": + return true, nil + default: + return false, nil + } +} + +func listGcloudSetupBillingAccounts(ctx context.Context, cfg config) ([]gcloudBillingAccountOption, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, + "gcloud", "billing", "accounts", "list", + "--configuration", cfg.gcloudConfiguration, + "--quiet", + "--format=value(name,displayName,open)", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\n%s", err, strings.TrimSpace(string(out))) + } + var accounts []gcloudBillingAccountOption + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(strings.TrimSpace(scanner.Text())) + if len(fields) == 0 { + continue + } + account := gcloudBillingAccountOption{Name: fields[0], Open: true} + if len(fields) > 1 { + last := strings.ToLower(fields[len(fields)-1]) + if last == "true" || last == "false" { + account.Open = last == "true" + account.DisplayName = strings.Join(fields[1:len(fields)-1], " ") + } else { + account.DisplayName = strings.Join(fields[1:], " ") + } + } + accounts = append(accounts, account) + } + return accounts, scanner.Err() +} + +func linkGcloudSetupBillingAccount(ctx context.Context, cfg config, project, billingAccount string, stdout io.Writer) error { + fmt.Fprintf(stdout, "linking GCP project %s to billing account %s\n", project, billingAccount) + cmd := exec.CommandContext(ctx, + "gcloud", "billing", "projects", "link", project, + "--billing-account", billingAccount, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + ) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + _, _ = stdout.Write(out) + } + if err != nil { + if gcloudSetupBillingQuotaExceeded(string(out)) { + return fmt.Errorf("link GCP project %s to billing account %s: billing quota exceeded; request a quota increase at https://support.google.com/code/contact/billing_quota_increase or choose a different billing account", project, billingAccount) + } + return fmt.Errorf("link GCP project %s to billing account %s: %w\n%s", project, billingAccount, err, strings.TrimSpace(string(out))) + } + return nil +} + +func gcloudSetupBillingQuotaExceeded(message string) bool { + msg := strings.ToLower(message) + return strings.Contains(msg, "billing quota exceeded") || + strings.Contains(msg, "billing_quota_increase") +} + +func waitForGcloudSetupBilling(ctx context.Context, cfg config, project string) error { + for i := 0; i < 12; i++ { + enabled, err := gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + time.Sleep(5 * time.Second) + } + return fmt.Errorf("GCP project %s billing was not visible as enabled before timeout", project) +} + +func maybeRepairGcloudQuotaProject(ctx context.Context, cfg config, project string, accessErr error, interactive bool, stdin io.Reader, stdout io.Writer) (bool, error) { + if !gcloudAuthNeedsLogin(accessErr.Error()) { + return false, nil + } + quotaProject := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "billing/quota_project") + if quotaProject == "" || quotaProject == project { + return false, nil + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + command := fmt.Sprintf("gcloud config set billing/quota_project %s --configuration %s", project, profile) + if !interactive { + return false, fmt.Errorf("gcloud profile %s uses quota project %s while target project is %s; run `%s`", profile, quotaProject, project, command) + } + fmt.Fprintf(stdout, "gcloud profile %s uses quota project %s while target project is %s.\n", profile, quotaProject, project) + fmt.Fprintf(stdout, "Set quota project to %s now? [Y/n] ", project) + answer := "" + _, _ = fmt.Fscanln(stdin, &answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "n" || answer == "no" { + return false, fmt.Errorf("gcloud profile %s cannot use quota project %s; run `%s`", profile, quotaProject, command) + } + cmd := exec.CommandContext(ctx, "gcloud", "config", "set", "billing/quota_project", project, "--configuration", profile) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("set gcloud quota project: %w", err) + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err != nil { + return false, fmt.Errorf("gcloud profile %s still cannot access project %s after setting quota project: %w", profile, project, err) + } + return true, nil +} + +func maybeEnableGcloudSetupProjectAPIs(ctx context.Context, cfg config, project string, accessErr error, stdout io.Writer) (bool, error) { + if !gcloudProjectServiceDisabled(accessErr.Error()) { + return false, nil + } + services := []string{ + "serviceusage.googleapis.com", + "cloudresourcemanager.googleapis.com", + } + if err := enableGcloudSetupProjectServices(ctx, cfg, project, services, stdout, "GCP project APIs"); err != nil { + return false, err + } + for i := 0; i < 12; i++ { + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return true, nil + } else if !gcloudProjectServiceDisabled(err.Error()) { + return false, err + } + time.Sleep(5 * time.Second) + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err != nil { + return false, fmt.Errorf("gcloud project %s is still not ready after enabling required APIs: %w", project, err) + } + return true, nil +} + +func enableGcloudSetupProjectServices(ctx context.Context, cfg config, project string, services []string, stdout io.Writer, label string) error { + fmt.Fprintf(stdout, "enabling required GCP project APIs for %s\n", project) + enableCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + args := append([]string{"services", "enable"}, services...) + args = append(args, + "--project", project, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + ) + cmd := exec.CommandContext(enableCtx, "gcloud", args...) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("enable %s for %s: %w", label, project, err) + } + return waitForGCPServicesEnabled(cfg, project, services, stdout, label) +} + +func gcloudSetupProjectAccess(ctx context.Context, cfg config, project string) error { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "projects", "describe", project, "--configuration", cfg.gcloudConfiguration, "--format=value(projectId)") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gcloud project access check failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("gcloud project access check returned no project for %s", project) + } + return nil +} + +func gcloudProjectServiceDisabled(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "service_disabled") || + strings.Contains(message, "api has not been used") || + strings.Contains(message, "it is disabled") +} + +func runGcloudSetupLogin(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + loginCommand := fmt.Sprintf("gcloud auth login --configuration %s --no-launch-browser", profile) + if !interactive { + return fmt.Errorf("gcloud profile %s needs authentication; run `%s`", profile, loginCommand) + } + fmt.Fprintf(stdout, "gcloud profile %s needs authentication or a different account.\n", profile) + fmt.Fprintf(stdout, "Starting `%s`.\n", loginCommand) + fmt.Fprintln(stdout, "Open the URL printed by gcloud, finish the OAuth flow, then paste the code if prompted.") + cmd := exec.CommandContext(ctx, "gcloud", "auth", "login", "--configuration", profile, "--no-launch-browser") + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud auth login failed: %w", err) + } + return nil +} + +func gcloudSetupAccessToken(ctx context.Context, cfg config) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "auth", "print-access-token", "--configuration", cfg.gcloudConfiguration) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("gcloud auth print-access-token failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", errors.New("gcloud auth print-access-token returned an empty token") + } + return token, nil +} + +func gcloudAuthNeedsLogin(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "reauthentication failed") || + strings.Contains(message, "gcloud auth login") || + strings.Contains(message, "no credential") || + strings.Contains(message, "invalid_grant") || + strings.Contains(message, "login required") || + strings.Contains(message, "user_project_denied") || + strings.Contains(message, "caller does not have required permission") || + strings.Contains(message, "does not have permission to access projects") +} + +type setupRegionDialogState struct { + Provider string + Profile string + Regions []string + Selected map[string]bool + Cursor int + Page int + Button int + Message string +} + +func runSetupRegionDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, provider, profile string, regions []string, selected []string) ([]string, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return nil, err + } + defer restore() + state := setupRegionDialogState{Provider: provider, Profile: profile, Regions: regions, Selected: map[string]bool{}, Button: -1} + for _, region := range selected { + if region = strings.TrimSpace(region); region != "" { + state.Selected[region] = true + } + } + for { + fmt.Fprint(stdout, renderSetupRegionDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, errSetupBack + } + return nil, err + } + switch b { + case 0x03: + return nil, errors.New("setup canceled") + case 0x04: + if regions, ok := state.deploy(); ok { + return regions, nil + } + case '\r', '\n', ' ', 'x', 'X': + if regions, ok := state.activate(); ok { + return regions, nil + } + if state.Button == 1 { + return nil, errSetupBack + } + case '\t': + state.tab() + case 'q', 'Q': + return nil, errSetupBack + case 0x1b: + next, err := reader.ReadByte() + if err != nil { + return nil, errSetupBack + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return nil, errSetupBack + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + return nil, errSetupBack + } + } +} + +func (s setupRegionDialogState) visibleRegions() []string { + if len(s.Regions) == 0 { + return nil + } + start := s.Page * setupRegionDialogItemsPerPage + if start >= len(s.Regions) { + start = 0 + } + end := minSetupDialogInt(start+setupRegionDialogItemsPerPage, len(s.Regions)) + return s.Regions[start:end] +} + +func (s *setupRegionDialogState) rows() int { + rows := len(s.visibleRegions()) + if len(s.Regions) > setupRegionDialogItemsPerPage { + rows++ + } + return rows +} + +func (s *setupRegionDialogState) up() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupRegionDialogState) down() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupRegionDialogState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupRegionDialogState) activate() ([]string, bool) { + if s.Button == 0 { + return s.deploy() + } + if s.Button == 1 { + return nil, false + } + visible := s.visibleRegions() + if s.Cursor < len(visible) { + region := visible[s.Cursor] + s.Selected[region] = !s.Selected[region] + return nil, false + } + if len(s.Regions) > setupRegionDialogItemsPerPage && s.Cursor == len(visible) { + pages := (len(s.Regions) + setupRegionDialogItemsPerPage - 1) / setupRegionDialogItemsPerPage + s.Page = (s.Page + 1) % pages + s.Cursor = 0 + } + return nil, false +} + +func (s *setupRegionDialogState) deploy() ([]string, bool) { + var selected []string + for _, region := range s.Regions { + if s.Selected[region] { + selected = append(selected, region) + } + } + if len(selected) > 0 { + return selected, true + } + s.Message = "Select at least one region before deploy." + return nil, false +} + +func renderSetupRegionDialogFrame(state setupRegionDialogState, rawMode bool) string { + rendered := renderSetupRegionDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupRegionDialogWithStyle(state setupRegionDialogState, style bool) string { + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + setupDialogRow(fmt.Sprintf("%s profile %s regions", state.Provider, state.Profile)), + "| |", + ) + visible := state.visibleRegions() + for i, region := range visible { + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + checked := " " + if state.Selected[region] { + checked = "x" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %s", marker, checked, region), setupDialogSectionStyle(style, state.Button < 0))) + } + if len(state.Regions) > setupRegionDialogItemsPerPage { + marker := " " + if state.Button < 0 && state.Cursor == len(visible) { + marker = ">" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s show next regions", marker), setupDialogSectionStyle(style, state.Button < 0))) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyled(state.Message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Controls: arrows move Space/Enter select Ctrl-D deploy"), + setupDialogRow("Tab rows/buttons Esc back Ctrl-C cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func runSetupDialog(stdin io.Reader, stdout io.Writer, initial setupSelection) (setupSelection, error) { + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupDialogWithRaw(reader, stdin, stdout, initial) +} + +func runSetupDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, initial setupSelection) (setupSelection, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return setupSelection{}, err + } + defer restore() + + state := setupDialogState{ + profiles: initial.Profiles, + keys: initial.Keys, + identityName: initial.IdentityName, + identityEmail: initial.IdentityEmail, + initialIdentityName: initial.IdentityName, + initialIdentityEmail: initial.IdentityEmail, + createProviders: setupAvailableCreateProviders(), + defaultCreate: initial.DefaultCreate, + defaultCreateByProvider: initial.DefaultCreateByProvider, + selectedProfiles: make([]bool, len(initial.Profiles)), + selectedKeys: make([]bool, len(initial.Keys)), + providerPages: map[string]int{}, + button: -1, + } + for i := range initial.Keys { + state.selectedKeys[i] = true + } + for { + fmt.Fprint(stdout, renderSetupDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return setupSelection{}, errors.New("setup canceled") + } + return setupSelection{}, err + } + if state.discardingCreatePaste || state.discardingIdentityPaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + continue + } + state.discardingCreatePaste = false + state.discardingIdentityPaste = false + } + switch b { + case 0x03: + return setupSelection{}, errors.New("setup canceled") + case 0x04: + if state.editingCreate { + state.editingCreate = false + state.editOriginal = "" + continue + } + if state.editingIdentity { + state.editingIdentity = false + state.editOriginal = "" + continue + } + if state.createProvider != "" { + if selected, ok := state.deployCreateProfile(); ok { + return selected, nil + } + continue + } + if selected, ok := state.deploy(); ok { + return selected, nil + } + case '\r', '\n': + if state.editingCreate { + state.editingCreate = false + state.editOriginal = "" + state.message = "" + continue + } + if state.editingIdentity { + state.editingIdentity = false + state.editOriginal = "" + state.message = "" + continue + } + if selected, ok := state.activate(); ok { + return selected, nil + } else if state.button == 1 { + return setupSelection{}, errors.New("setup canceled") + } + case ' ', 'x', 'X': + if state.editingCreate { + state.appendCreateByte(b) + continue + } + if state.editingIdentity { + state.appendIdentityByte(b) + continue + } + if selected, ok := state.activate(); ok { + return selected, nil + } else if state.button == 1 { + return setupSelection{}, errors.New("setup canceled") + } + case '\t': + state.discardingCreatePaste = false + state.tab() + case 'q', 'Q': + return setupSelection{}, errors.New("setup canceled") + case 0x7f, 0x08: + state.discardingCreatePaste = false + if state.editingCreate { + state.backspaceCreate() + } + if state.editingIdentity { + state.backspaceIdentity() + } + case 0x1b: + state.discardingCreatePaste = false + state.discardingIdentityPaste = false + if state.editingCreate { + state.setCreateFieldValue(state.editOriginal) + state.editingCreate = false + state.editOriginal = "" + state.message = "" + continue + } + if state.editingIdentity { + state.setIdentityFieldValue(state.editOriginal) + state.editingIdentity = false + state.editOriginal = "" + state.message = "" + continue + } + next, err := reader.ReadByte() + if err != nil { + if state.createProvider != "" { + state.cancelCreateProfile() + continue + } + return setupSelection{}, errors.New("setup canceled") + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return setupSelection{}, errors.New("setup canceled") + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + if state.createProvider != "" { + state.cancelCreateProfile() + continue + } + return setupSelection{}, errors.New("setup canceled") + default: + if state.editingCreate && b >= 32 && b <= 126 { + state.appendCreateByte(b) + } + if state.editingIdentity && b >= 32 && b <= 126 { + state.appendIdentityByte(b) + } + } + } +} + +func setupDialogRawMode(stdin io.Reader) (bool, func(), error) { + file, ok := stdin.(*os.File) + if !ok { + return false, func() {}, nil + } + fd := int(file.Fd()) + if !term.IsTerminal(fd) { + return false, func() {}, nil + } + state, err := term.MakeRaw(fd) + if err != nil { + return false, nil, err + } + return true, func() { + _ = term.Restore(fd, state) + fmt.Fprint(os.Stdout, "\x1b[?25h") + }, nil +} + +func renderSetupDialogFrame(state setupDialogState, rawMode bool) string { + rendered := renderSetupDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +type setupDialogState struct { + profiles []setupProfile + keys []setupSSHKey + createProviders []string + selectedProfiles []bool + selectedKeys []bool + providerPages map[string]int + identityName string + identityEmail string + initialIdentityName string + initialIdentityEmail string + cursor int + button int + message string + createProvider string + createName string + createAccessKey string + createSecretKey string + createRegion string + defaultCreate string + defaultCreateByProvider map[string]string + editingCreate bool + discardingCreatePaste bool + editingIdentity bool + discardingIdentityPaste bool + editOriginal string +} + +type setupDialogVisibleItem struct { + Kind string + Provider string + ProfileIndex int + KeyIndex int + Label string +} + +func setupAvailableCreateProviders() []string { + var providers []string + if _, err := exec.LookPath("gcloud"); err == nil { + providers = append(providers, "gcs") + } + if _, err := exec.LookPath("aws"); err == nil { + providers = append(providers, "s3") + } + return providers +} + +func (s setupDialogState) selection() setupSelection { + var profiles []setupProfile + for i, profile := range s.profiles { + if i < len(s.selectedProfiles) && s.selectedProfiles[i] { + profiles = append(profiles, profile) + } + } + var keys []setupSSHKey + for i, key := range s.keys { + if i < len(s.selectedKeys) && s.selectedKeys[i] { + keys = append(keys, key) + } + } + return setupSelection{ + Profiles: profiles, + Keys: keys, + IdentityName: strings.TrimSpace(s.identityName), + IdentityEmail: strings.TrimSpace(s.identityEmail), + } +} + +func (s *setupDialogState) rows() int { + if s.createProvider != "" { + return len(s.createFields()) + } + return len(s.visibleItems()) +} + +func (s *setupDialogState) up() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + if s.cursor == 0 { + s.cursor = s.rows() - 1 + return + } + s.cursor-- +} + +func (s *setupDialogState) down() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + s.cursor = (s.cursor + 1) % s.rows() +} + +func (s *setupDialogState) activate() (setupSelection, bool) { + if s.createProvider != "" { + if s.button == 0 { + return s.deployCreateProfile() + } + if s.button == 1 { + s.cancelCreateProfile() + return setupSelection{}, false + } + s.editingCreate = true + s.editOriginal = s.createFieldValue() + s.message = "" + return setupSelection{}, false + } + if s.button == 0 { + return s.deploy() + } + if s.button == 1 { + return setupSelection{}, false + } + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return setupSelection{}, false + } + s.message = "" + item := items[s.cursor] + switch item.Kind { + case "identity-name", "identity-email": + s.editingIdentity = true + s.editOriginal = s.identityFieldValue() + s.message = "" + case "create-profile": + s.createProvider = item.Provider + s.createName = firstNonEmpty(s.defaultCreateByProvider[item.Provider], s.defaultCreate) + s.editingCreate = true + s.editOriginal = s.createFieldValue() + s.cursor = 0 + s.button = -1 + return setupSelection{}, false + case "profile": + s.selectedProfiles[item.ProfileIndex] = !s.selectedProfiles[item.ProfileIndex] + case "more": + s.nextProviderPage(item.Provider) + case "key": + if item.KeyIndex >= 0 && item.KeyIndex < len(s.keys) { + s.selectedKeys[item.KeyIndex] = !s.selectedKeys[item.KeyIndex] + } + } + return setupSelection{}, false +} + +func (s *setupDialogState) deploy() (setupSelection, bool) { + selected := s.selection() + if len(selected.Profiles) == 0 && !s.identityChanged() { + s.message = "Select a cloud profile or change identity before deploy." + return setupSelection{}, false + } + return selected, true +} + +func (s setupDialogState) identityChanged() bool { + return strings.TrimSpace(s.identityName) != strings.TrimSpace(s.initialIdentityName) || + strings.TrimSpace(s.identityEmail) != strings.TrimSpace(s.initialIdentityEmail) +} + +func (s *setupDialogState) deployCreateProfile() (setupSelection, bool) { + name := strings.TrimSpace(s.createName) + if name == "" { + s.message = "Enter a profile name before OK." + return setupSelection{}, false + } + if !setupProfileNamePattern.MatchString(name) { + s.message = "Profile name can use letters, numbers, dot, dash, underscore, @, and +." + return setupSelection{}, false + } + if s.createProvider == "s3" { + accessKey := strings.TrimSpace(s.createAccessKey) + secretKey := strings.TrimSpace(s.createSecretKey) + region := strings.TrimSpace(s.createRegion) + if accessKey == "" { + s.message = "Enter an AWS access key ID before OK." + return setupSelection{}, false + } + if !setupAWSAccessKeyPattern.MatchString(accessKey) { + s.message = "AWS access key ID format looks invalid." + return setupSelection{}, false + } + if secretKey == "" { + s.message = "Enter an AWS secret access key before OK." + return setupSelection{}, false + } + if strings.ContainsAny(secretKey, " \t\r\n") || len(secretKey) < 20 { + s.message = "AWS secret access key format looks invalid." + return setupSelection{}, false + } + if region != "" && !setupAWSRegionPattern.MatchString(region) { + s.message = "AWS region format looks invalid." + return setupSelection{}, false + } + } + return setupSelection{ + Action: "create-profile", + CreateProvider: s.createProvider, + CreateName: name, + CreateAccessKey: strings.TrimSpace(s.createAccessKey), + CreateSecretKey: strings.TrimSpace(s.createSecretKey), + CreateRegion: strings.TrimSpace(s.createRegion), + }, true +} + +func (s *setupDialogState) cancelCreateProfile() { + s.createProvider = "" + s.createName = "" + s.createAccessKey = "" + s.createSecretKey = "" + s.createRegion = "" + s.editingCreate = false + s.discardingCreatePaste = false + s.editOriginal = "" + s.button = -1 + s.cursor = 0 + s.message = "" +} + +func (s *setupDialogState) appendCreateByte(b byte) { + if s.discardingCreatePaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + return + } + s.discardingCreatePaste = false + } + s.message = "" + if b == '\r' || b == '\n' { + s.editingCreate = false + s.editOriginal = "" + s.discardingCreatePaste = true + return + } + value := s.createFieldValue() + if len(value) >= 48 { + return + } + if b < 32 || b > 126 { + return + } + s.setCreateFieldValue(value + string(b)) +} + +func (s *setupDialogState) backspaceCreate() { + s.message = "" + value := s.createFieldValue() + if len(value) == 0 { + return + } + s.setCreateFieldValue(value[:len(value)-1]) +} + +func (s setupDialogState) createFields() []string { + if s.createProvider == "s3" { + return []string{"Profile name", "AWS Access Key ID", "AWS Secret Access Key", "Default region name"} + } + return []string{"Profile name"} +} + +func (s setupDialogState) createFieldValue() string { + return s.createFieldValueForRow(s.cursor) +} + +func (s setupDialogState) createFieldValueForRow(row int) string { + switch row { + case 1: + return s.createAccessKey + case 2: + return s.createSecretKey + case 3: + return s.createRegion + default: + return s.createName + } +} + +func (s *setupDialogState) setCreateFieldValue(value string) { + switch s.cursor { + case 1: + s.createAccessKey = value + case 2: + s.createSecretKey = value + case 3: + s.createRegion = value + default: + s.createName = value + } +} + +func (s *setupDialogState) tab() { + if s.editingCreate { + s.editingCreate = false + s.editOriginal = "" + } + if s.editingIdentity { + s.editingIdentity = false + s.editOriginal = "" + } + s.message = "" + if s.createProvider != "" { + if s.button == 1 { + s.button = -1 + return + } + if s.button < 0 { + s.button = 0 + return + } + s.button = (s.button + 1) % 2 + return + } + if s.button == 1 { + s.button = -1 + s.cursor = 0 + return + } + items := s.visibleItems() + if len(items) == 0 { + s.button = (s.button + 1) % 2 + return + } + currentProvider := "" + if s.cursor >= 0 && s.cursor < len(items) { + currentProvider = items[s.cursor].Provider + } + for _, provider := range setupDialogProviderOrder(s.profiles, s.createProviders) { + if currentProvider == "" || provider > currentProvider { + if idx := firstSetupDialogProviderItem(items, provider); idx >= 0 { + s.cursor = idx + s.button = 0 + return + } + } + } + if currentProvider != "ssh" { + if currentProvider != "identity" { + if idx := firstSetupDialogProviderItem(items, "identity"); idx >= 0 { + s.cursor = idx + s.button = -1 + return + } + } + if idx := firstSetupDialogProviderItem(items, "ssh"); idx >= 0 { + s.cursor = idx + s.button = -1 + return + } + } + if s.button < 0 { + s.button = 0 + } else { + s.button = (s.button + 1) % 2 + } +} + +func firstSetupDialogProviderItem(items []setupDialogVisibleItem, provider string) int { + for i, item := range items { + if item.Provider == provider { + return i + } + } + return -1 +} + +func (s *setupDialogState) nextProviderPage(provider string) { + indices := s.profileIndicesByProvider()[provider] + if len(indices) <= setupDialogProfilesPerProvider { + return + } + pages := (len(indices) + setupDialogProfilesPerProvider - 1) / setupDialogProfilesPerProvider + s.ensureProviderPages() + s.providerPages[provider] = (s.providerPages[provider] + 1) % pages + items := s.visibleItems() + if idx := firstSetupDialogProviderItem(items, provider); idx >= 0 { + s.cursor = idx + } +} + +func (s *setupDialogState) ensureProviderPages() { + if s.providerPages == nil { + s.providerPages = map[string]int{} + } +} + +func (s setupDialogState) visibleItems() []setupDialogVisibleItem { + var items []setupDialogVisibleItem + byProvider := s.profileIndicesByProvider() + for _, provider := range setupDialogProviderOrder(s.profiles, s.createProviders) { + indices := byProvider[provider] + page := 0 + if s.providerPages != nil { + page = s.providerPages[provider] + } + start := page * setupDialogProfilesPerProvider + if start >= len(indices) { + start = 0 + } + end := start + setupDialogProfilesPerProvider + if end > len(indices) { + end = len(indices) + } + for _, profileIndex := range indices[start:end] { + items = append(items, setupDialogVisibleItem{Kind: "profile", Provider: provider, ProfileIndex: profileIndex}) + } + if len(indices) > setupDialogProfilesPerProvider { + nextEnd := minSetupDialogInt(end+setupDialogProfilesPerProvider, len(indices)) + nextStart := end + 1 + if end >= len(indices) { + nextStart = 1 + nextEnd = minSetupDialogInt(setupDialogProfilesPerProvider, len(indices)) + } + items = append(items, setupDialogVisibleItem{ + Kind: "more", + Provider: provider, + Label: fmt.Sprintf("show next %s profiles (%d-%d of %d)", setupProviderLabel(provider), nextStart, nextEnd, len(indices)), + }) + } + if setupDialogCanCreateProvider(s.createProviders, provider) { + items = append(items, setupDialogVisibleItem{ + Kind: "create-profile", + Provider: provider, + Label: fmt.Sprintf("create new %s profile", setupProviderLabel(provider)), + }) + } + } + items = append(items, setupDialogVisibleItem{Kind: "identity-name", Provider: "identity", Label: "Name"}) + items = append(items, setupDialogVisibleItem{Kind: "identity-email", Provider: "identity", Label: "Email"}) + for i := range s.keys { + items = append(items, setupDialogVisibleItem{Kind: "key", Provider: "ssh", KeyIndex: i}) + } + return items +} + +func setupDialogCanCreateProvider(providers []string, provider string) bool { + for _, item := range providers { + if item == provider { + return true + } + } + return false +} + +func (s setupDialogState) profileIndicesByProvider() map[string][]int { + byProvider := map[string][]int{} + for i, profile := range s.profiles { + byProvider[profile.Provider] = append(byProvider[profile.Provider], i) + } + return byProvider +} + +func setupDialogProviderOrder(profiles []setupProfile, createProviders []string) []string { + seen := map[string]struct{}{} + for _, profile := range profiles { + seen[profile.Provider] = struct{}{} + } + for _, provider := range createProviders { + seen[provider] = struct{}{} + } + var order []string + for _, provider := range []string{"gcs", "s3"} { + if _, ok := seen[provider]; ok { + order = append(order, provider) + delete(seen, provider) + } + } + var rest []string + for provider := range seen { + rest = append(rest, provider) + } + sort.Strings(rest) + return append(order, rest...) +} + +func minSetupDialogInt(a, b int) int { + if a < b { + return a + } + return b +} + +func renderSetupDialog(state setupDialogState) string { + return renderSetupDialogWithStyle(state, false) +} + +func renderSetupDialogWithStyle(state setupDialogState, style bool) string { + if state.createProvider != "" { + return renderSetupCreateProfileDialogWithStyle(state, style) + } + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + "| Select cloud profiles to configure |", + "| |", + ) + activeSection := state.activeSection() + itemIndex := 0 + byProvider := state.profileIndicesByProvider() + for _, provider := range setupDialogProviderOrder(state.profiles, state.createProviders) { + lines = append(lines, setupDialogRowStyled(setupProviderLabel(provider)+" profiles", setupDialogSectionStyle(style, activeSection == provider))) + providerItems := setupDialogProviderVisibleItems(state, provider) + for _, item := range providerItems { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + switch item.Kind { + case "create-profile": + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %s", marker, item.Label), setupDialogSectionStyle(style, activeSection == provider))) + case "profile": + profile := state.profiles[item.ProfileIndex] + checked := " " + if item.ProfileIndex < len(state.selectedProfiles) && state.selectedProfiles[item.ProfileIndex] { + checked = "x" + } + detail := firstNonEmpty(profile.ProjectID, profile.AccountID, profile.Account, profile.ARN) + rowStyle := setupDialogSectionStyle(style, activeSection == provider) + if profile.Existing && style { + rowStyle += "\x1b[1;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %s:%-12s %-34s", marker, checked, setupProviderLabel(profile.Provider), setupProfileDisplayName(profile), detail), rowStyle)) + case "more": + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %s", marker, item.Label), setupDialogSectionStyle(style, activeSection == provider))) + } + itemIndex++ + } + if len(byProvider[provider]) == 0 { + lines = append(lines, setupDialogRowStyled(" none", setupDialogSectionStyle(style, activeSection == provider))) + } + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Identity", setupDialogSectionStyle(style, activeSection == "identity"))) + for _, field := range []struct { + kind string + label string + value string + }{ + {kind: "identity-name", label: "Name", value: state.identityName}, + {kind: "identity-email", label: "Email", value: state.identityEmail}, + } { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + active := activeSection == "identity" && state.button < 0 && state.cursor == itemIndex + inputStyle := setupDialogSectionStyle(style, activeSection == "identity") + if style && state.editingIdentity && active { + inputStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %-5s [%s]", marker, field.label, initDialogInputValue(field.value, 48, state.editingIdentity && active, style)), inputStyle)) + itemIndex++ + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Owner SSH keys", setupDialogSectionStyle(style, activeSection == "ssh"))) + for i, key := range state.keys { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + checked := " " + if i < len(state.selectedKeys) && state.selectedKeys[i] { + checked = "x" + } + label := firstNonEmpty(key.Comment, key.Source, shortSetupKey(key.PublicKey)) + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %-54s", marker, checked, label), setupDialogSectionStyle(style, activeSection == "ssh"))) + itemIndex++ + } + if len(state.keys) == 0 { + lines = append(lines, setupDialogRowStyled(" no SSH public keys found", setupDialogSectionStyle(style, activeSection == "ssh"))) + } + ok := "[ OK ]" + exit := "[ Exit ]" + okStyle := "" + exitStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if state.button == 1 { + ok = " OK " + exit = "[ EXIT ]" + if style { + exitStyle = "\x1b[44;97m" + } + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton(ok, okStyle)+" "+setupDialogButton(exit, exitStyle)), + setupDialogRow("Controls: arrows move Space/Enter select/edit Ctrl-D deploy"), + setupDialogRow("Tab sections/buttons Esc cancel/revert"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func renderSetupCreateProfileDialogWithStyle(state setupDialogState, style bool) string { + provider := strings.ToUpper(setupProviderLabel(state.createProvider)) + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + setupDialogRow(fmt.Sprintf("Create %s profile", provider)), + "| |", + ) + for i, label := range state.createFields() { + active := state.button < 0 && state.cursor == i + inputStyle := setupDialogSectionStyle(style, active) + if style && state.editingCreate && active { + inputStyle += "\x1b[44;97m" + } + marker := " " + if active { + marker = ">" + } + value := state.createFieldValueForRow(i) + if state.createProvider == "s3" && i == 2 { + value = strings.Repeat("*", len(value)) + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %-21s [%s]", marker, label, initDialogInputValue(value, 28, state.editingCreate && active, style)), inputStyle)) + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Enter edits/saves profile Ctrl-D OK"), + setupDialogRow("Tab field/buttons Esc back Ctrl-C cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func (s setupDialogState) activeSection() string { + if s.createProvider != "" { + return s.createProvider + } + if s.button >= 0 { + return "buttons" + } + items := s.visibleItems() + if s.cursor >= 0 && s.cursor < len(items) { + return items[s.cursor].Provider + } + return "" +} + +func (s setupDialogState) identityFieldValue() string { + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return "" + } + switch items[s.cursor].Kind { + case "identity-email": + return s.identityEmail + default: + return s.identityName + } +} + +func (s *setupDialogState) setIdentityFieldValue(value string) { + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return + } + switch items[s.cursor].Kind { + case "identity-email": + s.identityEmail = value + default: + s.identityName = value + } +} + +func (s *setupDialogState) appendIdentityByte(b byte) { + if s.discardingIdentityPaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + return + } + s.discardingIdentityPaste = false + } + s.message = "" + if b == '\r' || b == '\n' { + s.editingIdentity = false + s.editOriginal = "" + s.discardingIdentityPaste = true + return + } + value := s.identityFieldValue() + if len(value) >= 80 { + return + } + if b < 32 || b > 126 { + return + } + s.setIdentityFieldValue(value + string(b)) +} + +func (s *setupDialogState) backspaceIdentity() { + s.message = "" + value := s.identityFieldValue() + if len(value) == 0 { + return + } + s.setIdentityFieldValue(value[:len(value)-1]) +} + +func setupDialogSectionStyle(style, active bool) string { + if !style || !active { + return "" + } + return "\x1b[48;5;236m" +} + +func setupDialogANSI(style bool, code string) string { + if !style { + return "" + } + return "\x1b[" + code + "m" +} + +func setupDialogButton(text, style string) string { + if style == "" { + return text + } + return style + text + "\x1b[0m" +} + +func stripSetupANSI(value string) string { + var b strings.Builder + for i := 0; i < len(value); i++ { + if value[i] == 0x1b && i+1 < len(value) && value[i+1] == '[' { + i += 2 + for i < len(value) && (value[i] < '@' || value[i] > '~') { + i++ + } + continue + } + b.WriteByte(value[i]) + } + return b.String() +} + +func setupDialogProviderVisibleItems(state setupDialogState, provider string) []setupDialogVisibleItem { + var items []setupDialogVisibleItem + for _, item := range state.visibleItems() { + if item.Provider == provider { + items = append(items, item) + } + } + return items +} + +func setupDialogRow(text string) string { + visible := stripSetupANSI(text) + if len(visible) > 58 { + text = visible[:58] + visible = text + } + return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" +} + +func setupDialogRowStyled(text, style string) string { + visible := text + if len(visible) > 58 { + visible = visible[:58] + text = visible + } + if style != "" { + text = style + text + "\x1b[0m" + } + return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" +} + +func setupProviderLabel(provider string) string { + if provider == "s3" { + return "aws" + } + return "gcp" +} + +func setupProfileDisplayName(profile setupProfile) string { + if profile.Existing && strings.TrimSpace(profile.Region) != "" { + return profile.Name + "." + profile.Region + } + return profile.Name +} + +func shortSetupKey(key string) string { + fields := strings.Fields(key) + if len(fields) < 2 { + return key + } + part := fields[1] + if len(part) > 16 { + part = part[:16] + } + return fields[0] + " " + part +} + +func brokerUpsertOwners(brokerURL string, publicKeys []string) error { + return brokerPost(brokerURL, "/owners/upsert", brokerOwnerRequest{User: "owner", Role: "owner", PublicKeys: publicKeys}, nil) +} + +func upsertGlobalGCPProfile(cfg globalConfig, profile globalGCPProfile) globalConfig { + for i, existing := range cfg.GCPProfiles { + if existing.Name == profile.Name { + profile.Regions = mergeGlobalProfileRegions(existing.Regions, profile.Regions) + cfg.GCPProfiles[i] = profile + return cfg + } + } + cfg.GCPProfiles = append(cfg.GCPProfiles, profile) + return cfg +} + +func upsertGlobalAWSProfile(cfg globalConfig, profile globalAWSProfile) globalConfig { + for i, existing := range cfg.AWSProfiles { + if existing.Name == profile.Name { + profile.Regions = mergeGlobalProfileRegions(existing.Regions, profile.Regions) + cfg.AWSProfiles[i] = profile + return cfg + } + } + cfg.AWSProfiles = append(cfg.AWSProfiles, profile) + return cfg +} + +func mergeGlobalProfileRegions(existing, incoming []globalProfileRegion) []globalProfileRegion { + out := append([]globalProfileRegion{}, existing...) + for _, next := range incoming { + matched := false + for i := range out { + if out[i].Name == next.Name { + out[i] = next + matched = true + break + } + } + if !matched { + out = append(out, next) + } + } + return out +} diff --git a/setup_test.go b/setup_test.go new file mode 100644 index 0000000..2b8e818 --- /dev/null +++ b/setup_test.go @@ -0,0 +1,878 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAWSProfileFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + data := `[default] +region = us-east-1 + +[profile work] +region = eu-west-1 +` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + got := parseAWSProfileFile(path) + if strings.Join(got, ",") != "default,work" { + t.Fatalf("profiles = %#v", got) + } + if region := awsProfileFileValue(path, "work", "region"); region != "eu-west-1" { + t.Fatalf("region = %q", region) + } +} + +func TestResolveAWSSetupRegionUsesConfiguredRegion(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + got, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod", Region: "eu-west-1"}, "", false, strings.NewReader(""), ioDiscard{}) + if err != nil { + t.Fatal(err) + } + if got != "eu-west-1" { + t.Fatalf("region = %q", got) + } +} + +func TestResolveAWSSetupRegionPromptsFromEnabledRegions(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + got, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod"}, "", true, strings.NewReader("\x1b[B \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "eu-west-1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "AWS profile prod regions") || + !strings.Contains(stdout.String(), "us-east-1") || + !strings.Contains(stdout.String(), "eu-west-1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestResolveAWSSetupRegionYesModeRequiresRegion(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + errRegion, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod"}, "", false, strings.NewReader(""), ioDiscard{}) + if err == nil || errRegion != "" || !strings.Contains(err.Error(), "pass --region REGION") { + t.Fatalf("region=%q err=%v", errRegion, err) + } +} + +func TestResolveAWSSetupRegionRequiresAWSCLI(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + region, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod", Region: "eu-west-1"}, "", true, strings.NewReader(""), ioDiscard{}) + if err == nil || region != "" || !strings.Contains(err.Error(), "AWS CLI is not installed") { + t.Fatalf("region=%q err=%v", region, err) + } +} + +func TestResolveGCPSetupRegionUsesDialog(t *testing.T) { + var stdout bytes.Buffer + got, err := resolveGCPSetupRegion(setupProfile{Name: "work", Region: "us-central1"}, "", true, strings.NewReader("\x1b[B \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "us-east1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "GCP profile work regions") || + !strings.Contains(stdout.String(), "us-central1") || + !strings.Contains(stdout.String(), "us-east1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestResolveGCPSetupRegionUsesExistingConfiguredRegion(t *testing.T) { + var stdout bytes.Buffer + got, err := resolveGCPSetupRegion(setupProfile{Name: "work", Region: "europe-west1", Existing: true}, "", true, strings.NewReader("\x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "europe-west1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "[x] europe-west1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestConfiguredSetupProfilesCarryConfiguredRegion(t *testing.T) { + got := markConfiguredSetupProfiles([]setupProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + }}, globalConfig{GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}}) + if len(got) != 1 { + t.Fatalf("profiles = %#v", got) + } + if !got[0].Existing || got[0].Region != "europe-west1" { + t.Fatalf("profile = %#v", got[0]) + } +} + +func TestConfiguredSetupProfilesExpandConfiguredRegions(t *testing.T) { + got := markConfiguredSetupProfiles([]setupProfile{{ + Provider: "gcs", + Name: "work", + }}, globalConfig{GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}}) + if len(got) != 2 { + t.Fatalf("profiles = %#v", got) + } + if got[0].Name != "work" || got[0].Region != "us-central1" || !got[0].Existing { + t.Fatalf("first profile = %#v", got[0]) + } + if got[1].Name != "work" || got[1].Region != "europe-west1" || !got[1].Existing { + t.Fatalf("second profile = %#v", got[1]) + } +} + +func TestSetupDialogRendersCheckboxesAndKeys(t *testing.T) { + rendered := renderSetupDialog(setupDialogState{ + profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + ProjectID: "example-test-123456", + }}, + keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Source: "ssh-agent", + Comment: "dennis", + }}, + selectedProfiles: []bool{true}, + selectedKeys: []bool{true}, + }) + for _, want := range []string{"BUCKETGIT SETUP", "> [x] gcp:work", "Owner SSH keys", "[x] dennis", "[ OK ]", "[ Exit ]"} { + if !strings.Contains(rendered, want) { + t.Fatalf("dialog missing %q:\n%s", want, rendered) + } + } +} + +func TestSetupDialogPaginatesProfilesPerProvider(t *testing.T) { + var profiles []setupProfile + for i := 0; i < 12; i++ { + profiles = append(profiles, setupProfile{Provider: "gcs", Name: "gcp" + string(rune('a'+i))}) + } + for i := 0; i < 11; i++ { + profiles = append(profiles, setupProfile{Provider: "s3", Name: "aws" + string(rune('a'+i))}) + } + rendered := renderSetupDialog(setupDialogState{ + profiles: profiles, + selectedProfiles: make([]bool, len(profiles)), + providerPages: map[string]int{}, + }) + if strings.Count(rendered, "gcp:") != setupDialogProfilesPerProvider { + t.Fatalf("expected first GCP page only:\n%s", rendered) + } + if strings.Count(rendered, "aws:") != setupDialogProfilesPerProvider { + t.Fatalf("expected first AWS page only:\n%s", rendered) + } + for _, want := range []string{"show next gcp profiles", "show next aws profiles"} { + if !strings.Contains(rendered, want) { + t.Fatalf("dialog missing %q:\n%s", want, rendered) + } + } +} + +func TestSetupDialogTabJumpsBetweenProviders(t *testing.T) { + state := setupDialogState{ + profiles: []setupProfile{ + {Provider: "gcs", Name: "work"}, + {Provider: "s3", Name: "prod"}, + }, + selectedProfiles: make([]bool, 2), + providerPages: map[string]int{}, + } + state.tab() + items := state.visibleItems() + if state.cursor >= len(items) || items[state.cursor].Provider != "s3" { + t.Fatalf("tab should jump to AWS provider, cursor=%d items=%#v", state.cursor, items) + } +} + +func TestSetupDialogHandlesKeyboardSelection(t *testing.T) { + var stdout bytes.Buffer + selected, err := runSetupDialog(strings.NewReader(" \x04"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(selected.Profiles) != 1 { + t.Fatalf("profiles = %#v", selected.Profiles) + } + if len(selected.Keys) != 1 { + t.Fatalf("keys = %#v", selected.Keys) + } +} + +func TestSetupDialogPreselectsSSHKeys(t *testing.T) { + var stdout bytes.Buffer + selected, err := runSetupDialog(strings.NewReader(" \x04"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(selected.Keys) != 1 { + t.Fatalf("keys = %#v", selected.Keys) + } +} + +func TestSetupDialogCreatesAWSProfileInApp(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin) + var stdout bytes.Buffer + input := " demo\n\x1b[B\nAKIA1234567890ABCDEF\n\x1b[B\nsecretkeyvalue1234567890\n\x1b[B\nus-east-1\n\x04" + selected, err := runSetupDialog(strings.NewReader(input), &stdout, setupSelection{}) + if err != nil { + t.Fatalf("%v\n%s", err, stdout.String()) + } + if selected.Action != "create-profile" || selected.CreateProvider != "s3" { + t.Fatalf("selection = %#v", selected) + } + if selected.CreateName != "demo" || selected.CreateAccessKey != "AKIA1234567890ABCDEF" || selected.CreateSecretKey != "secretkeyvalue1234567890" || selected.CreateRegion != "us-east-1" { + t.Fatalf("selection = %#v", selected) + } + if !strings.Contains(stdout.String(), "Create AWS profile") || strings.Contains(stdout.String(), "AWS Access Key ID [None]") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupCreateProfileDefaultsAvoidExistingDefault(t *testing.T) { + defaults := setupCreateProfileDefaults([]setupProfile{{Provider: "s3", Name: "default"}}, setupOptions{}) + if defaults["s3"] != "" { + t.Fatalf("aws default = %q", defaults["s3"]) + } + if defaults["gcs"] != "default" { + t.Fatalf("gcp default = %q", defaults["gcs"]) + } +} + +func TestSetupCreateProfileValidationAndSingleLinePaste(t *testing.T) { + state := setupDialogState{createProvider: "s3", createName: "default", createAccessKey: "bad", createSecretKey: "secretkeyvalue1234567890"} + if _, ok := state.deployCreateProfile(); ok || !strings.Contains(state.message, "access key") { + t.Fatalf("message = %q ok=%v", state.message, ok) + } + state = setupDialogState{createProvider: "s3", editingCreate: true} + for _, b := range []byte("one\ntwo") { + state.appendCreateByte(b) + } + if state.createName != "one" { + t.Fatalf("createName = %q", state.createName) + } +} + +func TestCreateAWSProfileConfiguredUsesAWSConfigureSet(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "configure set aws_access_key_id AKIA1234567890ABCDEF --profile demo"}, + {match: "configure set aws_secret_access_key secretkeyvalue1234567890 --profile demo"}, + {match: "configure set region eu-west-1 --profile demo"}, + }) + t.Setenv("PATH", bin) + var stdout bytes.Buffer + if err := createAWSProfileConfigured("demo", "AKIA1234567890ABCDEF", "secretkeyvalue1234567890", "eu-west-1", &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "created AWS profile demo") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupDialogDoesNotDeployWithoutProfile(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader("\x04\x03"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } + if !strings.Contains(stdout.String(), "Select a cloud profile or change identity") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupDialogDeploysIdentityOnlyWhenChanged(t *testing.T) { + state := setupDialogState{ + identityName: "Dennis Example", + identityEmail: "dennis@example.com", + initialIdentityName: "BucketGit Client", + initialIdentityEmail: "dennis@bucketgit.com", + } + selected, ok := state.deploy() + if !ok { + t.Fatalf("deploy rejected identity-only change: %q", state.message) + } + if len(selected.Profiles) != 0 { + t.Fatalf("profiles = %#v", selected.Profiles) + } + if selected.IdentityName != "Dennis Example" || selected.IdentityEmail != "dennis@example.com" { + t.Fatalf("identity = %q <%s>", selected.IdentityName, selected.IdentityEmail) + } +} + +func TestSetupDialogEOFCancels(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader(""), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } +} + +func TestSetupDialogCtrlCCancels(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader("\x03"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } +} + +func TestSetupSSHKeyDiscoveryDedupesKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o755); err != nil { + t.Fatal(err) + } + key := "ssh-ed25519 AAAATEST dennis@example" + if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519.pub"), []byte(key+"\n"), 0o644); err != nil { + t.Fatal(err) + } + explicit := filepath.Join(home, "explicit.pub") + if err := os.WriteFile(explicit, []byte(key+"\n"), 0o644); err != nil { + t.Fatal(err) + } + keys, err := discoverSetupSSHKeys(setupSSHKeyOptions{Paths: []string{explicit}, NoAgent: true}) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 { + t.Fatalf("keys = %#v", keys) + } + if keys[0].PublicKey != "ssh-ed25519 AAAATEST" || keys[0].Comment != "dennis@example" { + t.Fatalf("key = %#v", keys[0]) + } +} + +func TestSetupCommandProvisionsGCPAndWritesGlobalConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + pubKey := filepath.Join(home, "owner.pub") + if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { + t.Fatal(err) + } + var ownerReq brokerOwnerRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/owners/upsert" { + t.Fatalf("unexpected broker path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&ownerReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "deployed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config configurations list", stdout: "work True"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "config get-value project", stdout: "example-test-123456"}, + {match: "auth print-access-token", stdout: "token"}, + {match: "billing projects describe example-test-123456", stdout: "True"}, + {match: "projects describe example-test-123456", stdout: "example-test-123456"}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: server.URL, requireFile: marker, exitCode: 1}, + {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, + {match: "firestore databases describe", exitCode: 1}, + {match: "firestore databases create"}, + {match: "iam service-accounts describe bgit-broker@example-test-123456.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding example-test-123456 --member=serviceAccount:bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@example-test-123456.iam.gserviceaccount.com", touch: marker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + configPath := filepath.Join(home, ".bgit", "config") + var stdout bytes.Buffer + err := setupCommand(nilContext{}, config{}, []string{"--yes", "--provider", "gcp", "--profile", "work", "--config", configPath, "--key", pubKey, "--no-agent", "--region", "europe-west1"}, strings.NewReader(""), &stdout) + if err != nil { + t.Fatal(err) + } + if len(ownerReq.PublicKeys) != 1 || ownerReq.Role != "owner" { + t.Fatalf("owner request = %#v", ownerReq) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.GCPProfiles) != 1 { + t.Fatalf("cfg = %#v", cfg) + } + profile := cfg.GCPProfiles[0] + if profile.Name != "work" || profile.ProjectID != "example-test-123456" || + len(profile.Regions) != 1 || profile.Regions[0].Name != "europe-west1" || + profile.Regions[0].BrokerURL != server.URL || profile.Regions[0].BrokerVersion != brokerVersion { + t.Fatalf("profile = %#v", profile) + } + if !strings.Contains(stdout.String(), "Next steps:") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupCommandOffersGCPProfileCreationWhenNoneExist(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + pubKey := filepath.Join(home, "owner.pub") + if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + bin := t.TempDir() + profileMarker := filepath.Join(t.TempDir(), "profile") + deployMarker := filepath.Join(t.TempDir(), "deployed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config configurations list", stdout: "new True", requireFile: profileMarker, exitCode: 1}, + {match: "config configurations create new", touch: profileMarker}, + {match: "auth login --configuration new"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "config get-value project", stdout: "example-test-123456"}, + {match: "auth print-access-token", stdout: "token"}, + {match: "billing projects describe example-test-123456", stdout: "True"}, + {match: "projects describe example-test-123456", stdout: "example-test-123456"}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: server.URL, requireFile: deployMarker, exitCode: 1}, + {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, + {match: "firestore databases describe", exitCode: 1}, + {match: "firestore databases create"}, + {match: "iam service-accounts describe bgit-broker@example-test-123456.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding example-test-123456 --member=serviceAccount:bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@example-test-123456.iam.gserviceaccount.com", touch: deployMarker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + configPath := filepath.Join(home, ".bgit", "config") + var stdout bytes.Buffer + err := setupCommand(nilContext{}, config{}, []string{"--provider", "gcp", "--profile", "new", "--config", configPath, "--key", pubKey, "--no-agent", "--region", "europe-west1"}, strings.NewReader("\n\n\x04 \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "create new gcp profile") || !strings.Contains(stdout.String(), "Profile name") || !strings.Contains(stdout.String(), "[new") { + t.Fatalf("stdout = %q", stdout.String()) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.GCPProfiles) != 1 || cfg.GCPProfiles[0].Name != "new" { + t.Fatalf("cfg = %#v", cfg) + } +} + +func TestEnsureGcloudSetupAuthRunsLoginOnReauth(t *testing.T) { + bin := t.TempDir() + authMarker := filepath.Join(t.TempDir(), "authed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "auth print-access-token", stdout: "token", missingStdout: "ERROR: Reauthentication failed. Please run: gcloud auth login", requireFile: authMarker, exitCode: 1}, + {match: "auth login --configuration work --no-launch-browser", stdout: "https://example.test/oauth", touch: authMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupAuth(context.Background(), config{gcloudConfiguration: "work"}, true, strings.NewReader("code\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Open the URL printed by gcloud") || + !strings.Contains(stdout.String(), "https://example.test/oauth") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupAuthYesModeDoesNotLaunchLogin(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "auth print-access-token", stdout: "ERROR: Reauthentication failed. Please run: gcloud auth login", exitCode: 1}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + err := ensureGcloudSetupAuth(context.Background(), config{gcloudConfiguration: "work"}, false, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "gcloud auth login --configuration work --no-launch-browser") { + t.Fatalf("err = %v", err) + } +} + +func TestEnsureGcloudSetupProjectAccessRunsLoginOnUserProjectDenied(t *testing.T) { + bin := t.TempDir() + authMarker := filepath.Join(t.TempDir(), "authed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project", stdout: "hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission", requireFile: authMarker, exitCode: 1}, + {match: "auth login --configuration default --no-launch-browser", stdout: "https://example.test/oauth", touch: authMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "default"}, true, strings.NewReader("code\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "needs authentication or a different account") || + !strings.Contains(stdout.String(), "https://example.test/oauth") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessRepairsQuotaProject(t *testing.T) { + bin := t.TempDir() + quotaMarker := filepath.Join(t.TempDir(), "quota") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project", stdout: "hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission to use project aafje-490407", requireFile: quotaMarker, exitCode: 1}, + {match: "config get-value billing/quota_project", stdout: "aafje-490407"}, + {match: "config set billing/quota_project hurozo", touch: quotaMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "default"}, true, strings.NewReader("\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "uses quota project aafje-490407") || + !strings.Contains(stdout.String(), "Set quota project to hurozo now?") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessSelectsExistingProjectWhenUnset(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list", stdout: "hurozo Hurozo"}, + {match: "config set project hurozo"}, + {match: "config set billing/quota_project hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "has no project configured") || + !strings.Contains(stdout.String(), "1. hurozo - Hurozo") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesProjectWhenUnset(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgit-test"}, + {match: "config set project bgit-test"}, + {match: "config set billing/quota_project bgit-test"}, + {match: "projects describe bgit-test", stdout: "bgit-test"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgit-test\nBucketGit Test\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "New project ID:") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessEnablesAPIsForFreshProject(t *testing.T) { + bin := t.TempDir() + apiMarker := filepath.Join(t.TempDir(), "apis") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgittest"}, + {match: "config set project bgittest"}, + {match: "config set billing/quota_project bgittest"}, + {match: "projects describe bgittest", stdout: "bgittest", missingStdout: "ERROR: SERVICE_DISABLED Cloud Resource Manager API has not been used in project bgittest before or it is disabled.", requireFile: apiMarker, exitCode: 1}, + {match: "services enable serviceusage.googleapis.com cloudresourcemanager.googleapis.com --project bgittest", touch: apiMarker}, + {match: "services list --enabled --project=bgittest", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgittest\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "enabling required GCP project APIs for bgittest") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesProjectWithSuffixOnCollision(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgit-test ", stdout: "ERROR: project ID already in use", exitCode: 1}, + {match: "projects create bgit-test-"}, + {match: "config set project bgit-test-"}, + {match: "config set billing/quota_project bgit-test-"}, + {match: "projects describe bgit-test-", stdout: "bgit-test"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgit-test\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Project display name [bgit-test]:") || + !strings.Contains(stdout.String(), "Project ID bgit-test is already in use; trying bgit-test-") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesShortProjectWithSuffix(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create demo-"}, + {match: "config set project demo-"}, + {match: "config set billing/quota_project demo-"}, + {match: "projects describe demo-", stdout: "demo"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\ndemo\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Project display name [demo]:") || + !strings.Contains(stdout.String(), "Project ID demo is not a valid GCP project ID; trying demo-") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingLinksSelectedAccount(t *testing.T) { + bin := t.TempDir() + billingMarker := filepath.Join(t.TempDir(), "billing") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest", stdout: "True", missingStdout: "False", requireFile: billingMarker, exitCode: 1}, + {match: "billing accounts list", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "does not have billing enabled") || + !strings.Contains(stdout.String(), "linking GCP project bgittest to billing account billingAccounts/123") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingEnablesCloudBillingAPI(t *testing.T) { + bin := t.TempDir() + apiMarker := filepath.Join(t.TempDir(), "billing-api") + billingMarker := filepath.Join(t.TempDir(), "billing") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest --configuration dennis --quiet --format=value(billingEnabled)", stdout: "True", onlyIfFile: billingMarker}, + {match: "billing projects describe bgittest --configuration dennis --quiet --format=value(billingEnabled)", stdout: "False", missingStdout: "ERROR: SERVICE_DISABLED Cloud Billing API has not been used in project bgittest before or it is disabled.", requireFile: apiMarker, exitCode: 1}, + {match: "services enable cloudbilling.googleapis.com --project bgittest", touch: apiMarker}, + {match: "services list --enabled --project=bgittest", stdout: "cloudbilling.googleapis.com"}, + {match: "billing accounts list --configuration dennis --quiet", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "enabling required GCP project APIs for bgittest") || + !strings.Contains(stdout.String(), "linking GCP project bgittest to billing account billingAccounts/123") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingYesModeReturnsLinkCommand(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest", stdout: "False"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", false, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "gcloud billing projects link bgittest --billing-account BILLING_ACCOUNT --configuration dennis") { + t.Fatalf("err = %v", err) + } +} + +func TestLinkGcloudSetupBillingAccountReportsQuotaExceeded(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects link bgittest --billing-account billingAccounts/123", stdout: "ERROR: Cloud billing quota exceeded: https://support.google.com/code/contact/billing_quota_increase", exitCode: 1}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := linkGcloudSetupBillingAccount(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", "billingAccounts/123", &stdout) + if err == nil || !strings.Contains(err.Error(), "billing quota exceeded") || + !strings.Contains(err.Error(), "choose a different billing account") { + t.Fatalf("err = %v", err) + } +} + +func TestGcloudSetupProjectIDWithSuffixTruncatesBase(t *testing.T) { + got := gcloudSetupProjectIDWithSuffix("very-long-project-id-base", "1234567") + if got != "very-long-project-id-b-1234567" { + t.Fatalf("project ID = %q", got) + } +} + +func TestGcloudIAMBindingRetryableDetectsServiceAccountPropagation(t *testing.T) { + if !gcloudIAMBindingRetryable("INVALID_ARGUMENT: Service account bgit-broker@project.iam.gserviceaccount.com does not exist.", errors.New("exit status 1")) { + t.Fatal("service account propagation error should be retryable") + } + if gcloudIAMBindingRetryable("PERMISSION_DENIED: permission denied", errors.New("exit status 1")) { + t.Fatal("permission denied should not be retryable") + } +} + +func TestBrokerDeleteAWSDeletesStackAndClearsConfig(t *testing.T) { + home := t.TempDir() + configPath := filepath.Join(home, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://broker.example.test", + BrokerVersion: brokerVersion, + }}, + }}, + }); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "cloudformation delete-stack --stack-name bgit-broker --region eu-west-1"}, + {match: "cloudformation wait stack-delete-complete --stack-name bgit-broker --region eu-west-1"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + if err := brokerCommand(nilContext{}, config{}, []string{"delete", "--provider", "aws", "--profile", "prod", "--region", "eu-west-1", "--config", configPath, "--yes"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.AWSProfiles[0].Regions) != 0 { + t.Fatalf("profile not cleared = %#v", cfg.AWSProfiles[0]) + } + if !strings.Contains(stdout.String(), "deleted AWS bgit broker") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestBrokerDeleteGCPDeletesFunctionAndOptionalData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, ".bgit", "config") + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "functions delete bgit-broker --gen2 --region europe-west1 --quiet"}, + {match: "run services delete bgit-broker --region europe-west1 --quiet"}, + {match: "firestore databases delete --database=bgit --quiet"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + if err := brokerCommand(nilContext{}, config{}, []string{"delete", "--provider", "gcp", "--profile", "work", "--region", "europe-west1", "--data", "--config", configPath, "--yes"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "deleted GCP bgit broker") { + t.Fatalf("stdout = %q", stdout.String()) + } +} diff --git a/ssh.go b/ssh.go index 2774510..f238d48 100644 --- a/ssh.go +++ b/ssh.go @@ -15,7 +15,9 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" + "time" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -34,17 +36,9 @@ type sshSetupOptions struct { func sshCommand(base config, args []string, stdout, stderr io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit ssh setup|scaffold [args]") + return errors.New("usage: bgit ssh git-upload-pack|git-receive-pack [args]") } switch args[0] { - case "setup": - return sshSetupCommand(base, args[1:], stdout, true) - case "scaffold": - return sshSetupCommand(base, args[1:], stdout, false) - case "repo": - return sshRepoCommand(base, args[1:], stdout) - case "keys": - return sshKeysCommand(base, args[1:], stdout) case "git-upload-pack", "git-receive-pack", "git-upload-archive": return sshGitServiceCommand(args, stdout) default: @@ -107,6 +101,11 @@ func configForSSHRepo(repo string) (config, error) { if repo == "" { return config{}, errors.New("missing repository path") } + if localCfg, err := readLocalConfig("."); err == nil && localCfg.logicalRepo != "" { + if strings.Trim(localCfg.logicalRepo, "/") == strings.Trim(repo, "/") { + return mergeSSHRepoAuth(localCfg), nil + } + } if strings.Contains(repo, "://") { cfg, _, err := parseRepoURI(repo) if err != nil { @@ -214,89 +213,6 @@ func mergeSSHRepoAuth(cfg config) config { return cfg } -func sshSetupCommand(base config, args []string, stdout io.Writer, includeKeys bool) error { - opts, repoArg, err := parseSSHSetupArgs(args) - if err != nil { - return err - } - cfg, err := sshSetupConfig(base, repoArg) - if err != nil { - return err - } - worktree, err := requireWorktree(".") - if err != nil { - return err - } - if err := writeBucketGitConfig(worktree, cfg); err != nil { - return err - } - sshURL := sshRemoteURL(cfg) - if err := setGitOrigin(worktree, sshURL); err != nil { - return err - } - for _, pair := range [][]string{ - {"core.sshCommand", "bgit ssh"}, - {"bucketgit.sshHost", defaultSSHHost}, - {"bucketgit.sshRemote", sshURL}, - } { - if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { - return err - } - } - brokerURL := "" - if strings.TrimSpace(opts.broker) != "" { - brokerURL = strings.TrimSpace(opts.broker) - if err := writeBrokerConfig(worktree, strings.TrimSpace(opts.broker), stdout); err != nil { - return err - } - } else if includeKeys { - discovered, err := discoverBrokerURL(cfg, opts) - if err != nil { - fmt.Fprintf(stdout, "broker not found; provisioning bgit-broker\n") - discovered, err = provisionBrokerURL(cfg, opts, stdout) - if err != nil { - return err - } - } - brokerURL = discovered - if err := writeBrokerConfig(worktree, brokerURL, stdout); err != nil { - return err - } - } - - fmt.Fprintf(stdout, "configured SSH origin %s\n", sshURL) - fmt.Fprintf(stdout, "configured core.sshCommand=bgit ssh\n") - if includeKeys { - keys, err := collectSSHPublicKeys(opts) - if err != nil { - return err - } - if len(keys) == 0 { - fmt.Fprintf(stdout, "no public keys found; add one later with bgit ssh setup --key PATH\n") - return nil - } - if err := writeSSHKeyDefaults(worktree, keys); err != nil { - return err - } - fmt.Fprintf(stdout, "recorded %d SSH public key default(s) for broker setup\n", len(keys)) - if brokerURL != "" { - if firstNonEmpty(cfg.provider, "gcs") == "gcs" && strings.TrimSpace(opts.broker) == "" { - if err := ensureGCPBrokerServices(cfg, stdout); err != nil { - return err - } - if err := ensureGCPBrokerFirestoreDatabase(cfg, opts, stdout); err != nil { - return err - } - } - if err := brokerUpsertRepo(brokerURL, cfg, "admin", keys); err != nil { - return err - } - fmt.Fprintf(stdout, "upserted repo %s with admin user admin\n", cfg.origin) - } - } - return nil -} - func parseSSHSetupArgs(args []string) (sshSetupOptions, string, error) { var opts sshSetupOptions var repoArg string @@ -353,10 +269,10 @@ func parseSSHSetupArgs(args []string) (sshSetupOptions, string, error) { opts.noAgent = true default: if strings.HasPrefix(arg, "-") { - return opts, "", fmt.Errorf("unsupported ssh setup option %s", arg) + return opts, "", fmt.Errorf("unsupported ssh option %s", arg) } if repoArg != "" { - return opts, "", errors.New("ssh setup accepts at most one repository URI") + return opts, "", errors.New("ssh commands accept at most one repository URI") } repoArg = arg } @@ -377,15 +293,24 @@ func sshSetupConfig(base config, repoArg string) (config, error) { return cfg, nil } cfg := base - if cfg.bucket == "" { + if cfg.bucket == "" && cfg.logicalRepo == "" { localCfg, err := readLocalConfig(".") if err != nil { - return config{}, errors.New("ssh setup requires a repository URI or an existing bgit origin") + return config{}, errors.New("ssh command requires a repository URI or an existing bgit origin") } cfg = mergeConfig(cfg, localCfg) } + if cfg.brokerURL != "" && cfg.logicalRepo != "" { + if cfg.branch == "" { + cfg.branch = defaultBranch + } + if cfg.origin == "" { + cfg.origin = fmt.Sprintf("git@%s:%s", defaultSSHHost, strings.Trim(cfg.logicalRepo, "/")) + } + return cfg, nil + } if cfg.bucket == "" || cfg.prefix == "" { - return config{}, errors.New("ssh setup requires a repository URI or an existing bgit origin") + return config{}, errors.New("ssh command requires a repository URI or an existing bgit origin") } if cfg.branch == "" { cfg.branch = defaultBranch @@ -396,39 +321,9 @@ func sshSetupConfig(base config, repoArg string) (config, error) { return cfg, nil } -func sshRepoCommand(base config, args []string, stdout io.Writer) error { - if len(args) == 0 || args[0] != "add" { - return errors.New("usage: bgit ssh repo add [--broker URL] [--key PATH] [--no-agent] [repo]") - } - opts, repoArg, err := parseSSHSetupArgs(args[1:]) - if err != nil { - return err - } - cfg, err := sshSetupConfig(base, repoArg) - if err != nil { - return err - } - brokerURL, err := brokerURLForCommand(opts) - if err != nil { - return err - } - keys, err := collectSSHPublicKeys(opts) - if err != nil { - return err - } - if err := brokerUpsertRepo(brokerURL, cfg, "admin", keys); err != nil { - return err - } - fmt.Fprintf(stdout, "upserted repo %s in broker %s\n", cfg.origin, brokerURL) - if len(keys) > 0 { - fmt.Fprintf(stdout, "added %d admin key(s) for user admin\n", len(keys)) - } - return nil -} - func sshKeysCommand(base config, args []string, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit ssh keys list|add|remove|suspend [args]") + return errors.New("usage: bgit admin keys list|add|remove|suspend [args]") } action := args[0] opts, repoArg, err := parseSSHKeyArgs(args[1:]) @@ -463,7 +358,7 @@ func sshKeysCommand(base config, args []string, stdout io.Writer) error { return err } if len(keys) == 0 { - return errors.New("ssh keys add requires --key or a key loaded in ssh-agent") + return errors.New("admin keys add requires --key or a key loaded in ssh-agent") } if err := brokerAddKeys(brokerURL, cfg, opts.user, opts.role, keys); err != nil { return err @@ -491,7 +386,7 @@ func sshKeysCommand(base config, args []string, stdout io.Writer) error { fmt.Fprintf(stdout, "suspended key %s\n", identity) return nil default: - return fmt.Errorf("unknown ssh keys command %q", action) + return fmt.Errorf("unknown admin keys command %q", action) } } @@ -556,7 +451,7 @@ func parseSSHKeyArgs(args []string) (sshKeyOptions, string, error) { opts.fingerprint = value default: if strings.HasPrefix(arg, "-") { - return opts, "", fmt.Errorf("unsupported ssh keys option %s", arg) + return opts, "", fmt.Errorf("unsupported admin keys option %s", arg) } if repoArg == "" && strings.Contains(arg, "://") { repoArg = arg @@ -570,7 +465,7 @@ func parseSSHKeyArgs(args []string) (sshKeyOptions, string, error) { repoArg = arg continue } - return opts, "", errors.New("too many ssh keys arguments") + return opts, "", errors.New("too many admin keys arguments") } } return opts, repoArg, nil @@ -613,7 +508,7 @@ func brokerURLForCommand(opts sshSetupOptions) (string, error) { return value, nil } } - return "", errors.New("broker URL is required; run bgit ssh setup or pass --broker URL") + return "", errors.New("broker URL is required; run bgit setup/init or pass --broker URL") } func sshRemoteURL(cfg config) string { @@ -637,12 +532,14 @@ type brokerRepo struct { Bucket string `json:"bucket"` Prefix string `json:"prefix"` Origin string `json:"origin"` + Logical string `json:"logical,omitempty"` } type brokerKey struct { User string `json:"user"` Role string `json:"role"` PublicKey string `json:"public_key"` + Source string `json:"source,omitempty"` Suspended bool `json:"suspended,omitempty"` } @@ -659,6 +556,7 @@ type brokerKeyRequest struct { Role string `json:"role,omitempty"` PublicKeys []string `json:"public_keys,omitempty"` Key string `json:"key,omitempty"` + Source string `json:"source,omitempty"` } type brokerAuthRequest struct { @@ -683,13 +581,14 @@ type brokerKeysResponse struct { Keys []brokerKey `json:"keys"` } -func brokerUpsertRepo(brokerURL string, cfg config, adminUser string, publicKeys []string) error { - req := brokerRepoRequest{ - Repo: repoForBroker(cfg), - AdminUser: adminUser, - PublicKeys: publicKeys, - Role: "admin", +func brokerUpsertLogicalRepo(brokerURL, provider, logicalRepo string) error { + cfg := config{ + provider: provider, + prefix: strings.Trim(logicalRepo, "/"), + logicalRepo: strings.Trim(logicalRepo, "/"), + origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, strings.Trim(logicalRepo, "/")), } + req := brokerRepoRequest{Repo: repoForBroker(cfg)} return brokerPost(brokerURL, "/repos/upsert", req, nil) } @@ -702,11 +601,20 @@ func brokerListKeys(brokerURL string, cfg config) ([]brokerKey, error) { } func brokerAddKeys(brokerURL string, cfg config, user, role string, publicKeys []string) error { + return brokerAddKeysWithSource(brokerURL, cfg, user, role, "", publicKeys) +} + +func brokerAddKeysWithSource(brokerURL string, cfg config, user, role, source string, publicKeys []string) error { + role = normalizeBrokerRole(role) + if !validBrokerRole(role) { + return fmt.Errorf("invalid broker role %q", role) + } req := brokerKeyRequest{ Repo: repoForBroker(cfg), User: user, Role: role, PublicKeys: publicKeys, + Source: source, } return brokerPost(brokerURL, "/keys/add", req, nil) } @@ -715,6 +623,24 @@ func brokerMutateKey(brokerURL, path string, cfg config, key string) error { return brokerPost(brokerURL, path, brokerKeyRequest{Repo: repoForBroker(cfg), Key: key}, nil) } +func validBrokerRole(role string) bool { + switch strings.TrimSpace(role) { + case "owner", "admin", "maintainer", "developer", "triage", "read": + return true + default: + return false + } +} + +func normalizeBrokerRole(role string) string { + switch strings.TrimSpace(role) { + case "write": + return "developer" + default: + return strings.TrimSpace(role) + } +} + func brokerUpdateRef(brokerURL string, cfg config, ref, oldHash, newHash string) error { req := brokerRefUpdateRequest{ Repo: repoForBroker(cfg), @@ -776,7 +702,7 @@ func brokerURLForSSHService(cfg config) (string, error) { } url, err := discoverBrokerURL(cfg, sshSetupOptions{}) if err != nil { - return "", fmt.Errorf("broker URL is required for SSH Git access; run bgit ssh setup: %w", err) + return "", fmt.Errorf("broker URL is required for SSH Git access; run bgit init: %w", err) } return url, nil } @@ -785,11 +711,13 @@ func repoForBroker(cfg config) brokerRepo { if cfg.origin == "" { cfg.origin = originForConfig(cfg) } + logical := strings.Trim(firstNonEmpty(cfg.logicalRepo, cfg.prefix), "/") return brokerRepo{ Provider: firstNonEmpty(cfg.provider, "gcs"), Bucket: cfg.bucket, Prefix: strings.Trim(cfg.prefix, "/"), Origin: cfg.origin, + Logical: logical, } } @@ -915,15 +843,13 @@ func discoverAWSBrokerURL(cfg config, opts sshSetupOptions) (string, error) { region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) profile := strings.TrimSpace(cfg.gcloudConfiguration) args := []string{"cloudformation", "describe-stacks", "--stack-name", "bgit-broker", "--region", region, "--query", "Stacks[0].Outputs[?OutputKey=='BrokerUrl'].OutputValue | [0]", "--output", "text"} - args = appendAWSProfile(args, profile) - if out, err := exec.Command("aws", args...).Output(); err == nil { + if out, err := awsCommand(context.Background(), profile, args...).Output(); err == nil { if url := cleanBrokerURL(string(out)); url != "" { return url, nil } } args = []string{"ssm", "get-parameter", "--name", "/bgit/broker/default/url", "--region", region, "--query", "Parameter.Value", "--output", "text"} - args = appendAWSProfile(args, profile) - if out, err := exec.Command("aws", args...).Output(); err == nil { + if out, err := awsCommand(context.Background(), profile, args...).Output(); err == nil { if url := cleanBrokerURL(string(out)); url != "" { return url, nil } @@ -950,6 +876,16 @@ func provisionGCPBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( if err := ensureGCPBrokerFirestoreDatabase(cfg, opts, stdout); err != nil { return "", err } + serviceAccount, err := ensureGCPBrokerServiceAccount(cfg, stdout) + if err != nil { + return "", err + } + if err := ensureGCPBrokerRuntimePermissions(cfg, serviceAccount, stdout); err != nil { + return "", err + } + if err := ensureGCPBrokerDeployerPermission(cfg, serviceAccount, stdout); err != nil { + return "", err + } sourceDir, err := os.MkdirTemp("", "bgit-gcp-broker-*") if err != nil { return "", err @@ -968,340 +904,343 @@ func provisionGCPBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( "--entry-point", "broker", "--trigger-http", "--allow-unauthenticated", - "--set-env-vars", "FIRESTORE_DATABASE="+gcpBrokerFirestoreDatabase(opts), + "--service-account", serviceAccount, + "--set-env-vars", "FIRESTORE_DATABASE="+gcpBrokerFirestoreDatabase(opts)+",BROKER_VERSION="+brokerVersion+",BGIT_SIGNING_SERVICE_ACCOUNT="+serviceAccount, "--quiet", ) out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("deploy GCP bgit broker: %w\n%s", err, strings.TrimSpace(string(out))) } + if err := ensureGCPBrokerSigningPermission(cfg, serviceAccount, stdout); err != nil { + return "", err + } return discoverGCPBrokerURL(cfg, opts) } func ensureGCPBrokerServices(cfg config, stdout io.Writer) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } services := []string{ + "serviceusage.googleapis.com", + "cloudresourcemanager.googleapis.com", "cloudfunctions.googleapis.com", "run.googleapis.com", "cloudbuild.googleapis.com", "artifactregistry.googleapis.com", "firestore.googleapis.com", + "iamcredentials.googleapis.com", } fmt.Fprintf(stdout, "ensuring GCP broker APIs are enabled\n") args := append([]string{"services", "enable"}, services...) + args = append(args, "--project="+project, "--quiet") cmd := gcloudCommand(cfg.gcloudConfiguration, args...) out, err := cmd.CombinedOutput() if err != nil { + if gcpBrokerServicesNeedBilling(string(out)) { + return fmt.Errorf("enable GCP broker APIs: project %s does not have billing enabled; link a billing account with `gcloud billing projects link %s --billing-account BILLING_ACCOUNT` and rerun setup\n%s", project, project, strings.TrimSpace(string(out))) + } return fmt.Errorf("enable GCP broker APIs: %w\n%s", err, strings.TrimSpace(string(out))) } + if err := waitForGCPBrokerServices(cfg, project, services, stdout); err != nil { + return err + } return nil } -func ensureGCPBrokerFirestoreDatabase(cfg config, opts sshSetupOptions, stdout io.Writer) error { - database := gcpBrokerFirestoreDatabase(opts) - region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) - location := firstNonEmpty(strings.TrimSpace(opts.firestoreLocation), os.Getenv("BGIT_FIRESTORE_LOCATION"), region) +func gcpBrokerServicesNeedBilling(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "billing account") && strings.Contains(message, "not found") || + strings.Contains(message, "billing must be enabled") || + strings.Contains(message, "ureq_project_billing_not_found") || + strings.Contains(message, "billing-enabled") +} + +func waitForGCPBrokerServices(cfg config, project string, services []string, stdout io.Writer) error { + return waitForGCPServicesEnabled(cfg, project, services, stdout, "GCP broker APIs") +} + +func waitForGCPServicesEnabled(cfg config, project string, services []string, stdout io.Writer, label string) error { + want := map[string]struct{}{} + for _, service := range services { + want[service] = struct{}{} + } + var lastMissing []string + for i := 0; i < 24; i++ { + enabled, err := gcpEnabledServices(cfg, project) + if err == nil { + missing := missingGCPServices(want, enabled) + if len(missing) == 0 { + return nil + } + lastMissing = missing + } + if i == 0 { + fmt.Fprintf(stdout, "waiting for %s to become enabled\n", label) + } + time.Sleep(5 * time.Second) + } + if len(lastMissing) == 0 { + return fmt.Errorf("%s were not visible as enabled before timeout", label) + } + return fmt.Errorf("%s were not visible as enabled before timeout: %s", label, strings.Join(lastMissing, ", ")) +} + +func gcpEnabledServices(cfg config, project string) (map[string]struct{}, error) { + cmd := gcloudCommand(cfg.gcloudConfiguration, + "services", "list", + "--enabled", + "--project="+project, + "--format=value(config.name)", + ) + out, err := cmd.Output() + if err != nil { + return nil, err + } + enabled := map[string]struct{}{} + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + for _, service := range strings.Fields(scanner.Text()) { + enabled[service] = struct{}{} + } + } + return enabled, scanner.Err() +} + +func missingGCPServices(want map[string]struct{}, enabled map[string]struct{}) []string { + var missing []string + for service := range want { + if _, ok := enabled[service]; !ok { + missing = append(missing, service) + } + } + sort.Strings(missing) + return missing +} + +func ensureGCPBrokerServiceAccount(cfg config, stdout io.Writer) (string, error) { + project := gcloudProject(cfg) + if project == "" { + return "", errors.New("GCP project is not configured") + } + email := gcpBrokerServiceAccountEmail(project) describe := gcloudCommand(cfg.gcloudConfiguration, - "firestore", "databases", "describe", - "--database="+database, - "--format=value(name)", + "iam", "service-accounts", "describe", email, + "--format=value(email)", ) if out, err := describe.Output(); err == nil && strings.TrimSpace(string(out)) != "" { - return nil + fmt.Fprintf(stdout, "using GCP broker service account %s\n", email) + return email, nil } - fmt.Fprintf(stdout, "creating Firestore database %s in %s\n", database, location) + fmt.Fprintf(stdout, "creating GCP broker service account %s\n", email) create := gcloudCommand(cfg.gcloudConfiguration, - "firestore", "databases", "create", - "--database="+database, - "--location="+location, - "--type=firestore-native", + "iam", "service-accounts", "create", "bgit-broker", + "--display-name=BucketGit Broker", + "--project="+project, "--quiet", ) out, err := create.CombinedOutput() if err != nil { - return fmt.Errorf("create GCP Firestore database %s in %s: %w\n%s", database, location, err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("create GCP broker service account %s: %w\n%s", email, err, strings.TrimSpace(string(out))) } - return nil + return email, nil } -func gcpBrokerFirestoreDatabase(opts sshSetupOptions) string { - return firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") +func gcpBrokerServiceAccountEmail(project string) string { + return "bgit-broker@" + project + ".iam.gserviceaccount.com" } -func writeGCPBrokerSource(dir string) error { - files := map[string]string{ - "package.json": `{"scripts":{"start":"functions-framework --target=broker"},"dependencies":{"@google-cloud/functions-framework":"^3.4.0","@google-cloud/firestore":"^7.10.0","@google-cloud/storage":"^7.16.0"}} -`, - "index.js": `'use strict'; - -const crypto = require('crypto'); -const {Firestore} = require('@google-cloud/firestore'); -const {Storage} = require('@google-cloud/storage'); -const db = new Firestore({databaseId: process.env.FIRESTORE_DATABASE || 'bgit'}); -const repos = db.collection('bgit_broker_repos'); -const storage = new Storage(); - -function repoID(repo) { - return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); +func ensureGCPBrokerRuntimePermissions(cfg config, serviceAccount string, stdout io.Writer) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + for _, role := range []string{"roles/datastore.user", "roles/storage.admin"} { + fmt.Fprintf(stdout, "granting GCP broker %s to %s\n", role, serviceAccount) + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member=serviceAccount:"+serviceAccount, + "--role="+role, + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant GCP broker %s: %w\n%s", role, err, strings.TrimSpace(string(out))) + } + } + return nil } -function docID(repo) { - return Buffer.from(repoID(repo)).toString('base64url'); +func ensureGCPBrokerDeployerPermission(cfg config, serviceAccount string, stdout io.Writer) error { + account := gcloudAccount(cfg) + if account == "" { + return nil + } + member := "user:" + account + if strings.HasSuffix(account, ".gserviceaccount.com") { + member = "serviceAccount:" + account + } + fmt.Fprintf(stdout, "granting GCP broker deploy permission to %s\n", member) + cmd := gcloudCommand(cfg.gcloudConfiguration, + "iam", "service-accounts", "add-iam-policy-binding", serviceAccount, + "--member="+member, + "--role=roles/iam.serviceAccountUser", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + if fallbackErr := ensureGCPBrokerProjectDeployerPermission(cfg, member); fallbackErr != nil { + return fmt.Errorf("grant GCP broker deploy permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + } + return nil } -async function loadRepo(repo) { - const ref = repos.doc(docID(repo)); - const snap = await ref.get(); - if (!snap.exists) return {ref, data: {repo, keys: []}}; - const data = snap.data() || {}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - return {ref, data}; +func ensureGCPBrokerProjectDeployerPermission(cfg config, member string) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member="+member, + "--role=roles/iam.serviceAccountUser", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant project-level deploy permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil } -async function saveRepo(entry) { - await entry.ref.set(entry.data, {merge: true}); +func ensureGCPBrokerSigningPermission(cfg config, serviceAccount string, stdout io.Writer) error { + fmt.Fprintf(stdout, "granting GCP broker signBlob permission to %s\n", serviceAccount) + args := []string{ + "iam", "service-accounts", "add-iam-policy-binding", serviceAccount, + "--member=serviceAccount:" + serviceAccount, + "--role=roles/iam.serviceAccountTokenCreator", + "--quiet", + } + if project := gcloudProject(cfg); project != "" { + args = append(args, "--project="+project) + } + cmd := gcloudCommand(cfg.gcloudConfiguration, args...) + bindOut, bindErr := runGcloudIAMBindingWithRetry(cmd) + if bindErr != nil { + if err := ensureGCPBrokerProjectSigningPermission(cfg, serviceAccount); err != nil { + return fmt.Errorf("grant GCP broker signBlob permission: %w\n%s", bindErr, strings.TrimSpace(string(bindOut))) + } + } + return nil } -function readSSHString(buf, offset) { - const len = buf.readUInt32BE(offset); - const start = offset + 4; - return {value: buf.subarray(start, start + len), offset: start + len}; +func ensureGCPBrokerProjectSigningPermission(cfg config, serviceAccount string) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member=serviceAccount:"+serviceAccount, + "--role=roles/iam.serviceAccountTokenCreator", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant project-level signBlob permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil } -function rawBody(req) { - if (req.rawBody) return Buffer.from(req.rawBody); - return Buffer.from(JSON.stringify(req.body || {})); +func runGcloudIAMBindingWithRetry(cmd *exec.Cmd) ([]byte, error) { + out, err := cmd.CombinedOutput() + if err == nil || !gcloudIAMBindingRetryable(string(out), err) { + return out, err + } + var lastOut []byte + var lastErr error + for attempt := 0; attempt < 8; attempt++ { + time.Sleep(time.Duration(attempt+1) * time.Second) + retry := exec.Command(cmd.Path, cmd.Args[1:]...) + retry.Env = cmd.Env + retry.Dir = cmd.Dir + lastOut, lastErr = retry.CombinedOutput() + if lastErr == nil || !gcloudIAMBindingRetryable(string(lastOut), lastErr) { + return lastOut, lastErr + } + } + return lastOut, lastErr } -function expectedMessage(req) { - const digest = crypto.createHash('sha256').update(rawBody(req)).digest('base64'); - return Buffer.from('bgit-broker-v1\n' + digest).toString('base64'); -} - -function normalizeKey(key) { - return String(key || '').trim().split(/\s+/).slice(0, 2).join(' '); +func gcloudIAMBindingRetryable(out string, err error) bool { + if err == nil { + return false + } + message := strings.ToLower(out + "\n" + err.Error()) + return strings.Contains(message, "service account") && + (strings.Contains(message, "does not exist") || + strings.Contains(message, "not found") || + strings.Contains(message, "principal") && strings.Contains(message, "not found")) } -function publicKeyObject(publicKey) { - const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== 'ssh-ed25519') return crypto.createPublicKey(publicKey); - const blob = Buffer.from(parts[1], 'base64'); - let parsed = readSSHString(blob, 0); - const alg = parsed.value.toString(); - if (alg !== 'ssh-ed25519') throw new Error('unsupported SSH key algorithm'); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from('302a300506032b6570032100', 'hex'); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: 'der', type: 'spki'}); +func gcloudAccount(cfg config) string { + out, err := gcloudCommand(cfg.gcloudConfiguration, "config", "get-value", "account", "--quiet").Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value } -function verifySignature(req, entry) { - const adminKeys = (entry.data.keys || []).filter((k) => k.role === 'admin' && !k.suspended); - if (adminKeys.length === 0) return true; - const key = signedKey(req, entry); - return !!key && key.role === 'admin'; +func gcloudProject(cfg config) string { + out, err := gcloudCommand(cfg.gcloudConfiguration, "config", "get-value", "project", "--quiet").Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value } -function signedKey(req, entry) { - const keys = (entry.data.keys || []).filter((k) => !k.suspended); - const publicKey = normalizeKey(req.get('x-bgit-key')); - const message = String(req.get('x-bgit-signature-message') || ''); - const signature = String(req.get('x-bgit-signature') || ''); - if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; - const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); - if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; - const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; - if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; - return key; -} - -function roleAllows(role, operation) { - if (role === 'admin') return true; - if (operation === 'read') return role === 'read' || role === 'write'; - if (operation === 'write') return role === 'write'; - return false; -} - -function cleanObjectPath(value) { - const path = String(value || '').replace(/^\/+/, ''); - if (path.includes('\0')) throw new Error('invalid object path'); - return path; -} - -function objectName(repo, objectPath) { - const prefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); - const path = cleanObjectPath(objectPath); - return prefix ? prefix + '/' + path : path; -} - -function requireRead(req, entry) { - const key = signedKey(req, entry); - if (!key || !roleAllows(key.role, 'read')) { - const err = new Error('read SSH signature required'); - err.status = 403; - throw err; - } -} - -async function readObject(repo, objectPath) { - const [data] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).download(); - return data.toString('base64'); -} - -async function listObjects(repo, prefix) { - const repoPrefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); - const queryPrefix = objectName(repo, prefix); - const [files] = await storage.bucket(repo.bucket).getFiles({prefix: queryPrefix}); - const strip = repoPrefix ? repoPrefix + '/' : ''; - return files.map((file) => file.name.startsWith(strip) ? file.name.slice(strip.length) : file.name); -} - -async function updateRefCAS(repo, ref, oldHash, newHash) { - const id = docID(repo); - const refDoc = repos.doc(id); - await db.runTransaction(async (tx) => { - const snap = await tx.get(refDoc); - const data = snap.exists ? (snap.data() || {}) : {repo, keys: [], refs: {}}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - data.refs = data.refs || {}; - const zero = '0000000000000000000000000000000000000000'; - const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; - if (current !== oldHash) { - const err = new Error('stale ref'); - err.status = 409; - throw err; - } - if (newHash === zero) { - delete data.refs[ref]; - } else { - data.refs[ref] = newHash; - } - tx.set(refDoc, data, {merge: true}); - }); -} - -async function ensureRepo(repo) { - const id = repoID(repo); - if (!repo || !repo.bucket || !repo.prefix) throw new Error('repo is required'); - return loadRepo(repo); -} - -function requireAdmin(req, entry) { - if (!verifySignature(req, entry)) { - const err = new Error('admin SSH signature required'); - err.status = 403; - throw err; - } -} - -exports.broker = async (req, res) => { - res.set('content-type', 'application/json'); - if (req.path === '/health' || req.path === '/') { - res.status(200).send(JSON.stringify({ok: true, service: 'bgit-broker'})); - return; - } - try { - const body = req.body || {}; - if (req.path === '/repos/upsert' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const user = body.admin_user || 'admin'; - const role = body.role || 'admin'; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) { - entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if (req.path === '/keys/list' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - res.status(200).send(JSON.stringify({keys: entry.data.keys})); - return; - } - if (req.path === '/keys/add' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const user = body.user || 'admin'; - const role = body.role || 'read'; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) { - entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const key = String(body.key || '').trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); - if (req.path === '/keys/remove') { - entry.data.keys = entry.data.keys.filter((k) => !match(k)); - } else { - for (const item of entry.data.keys) if (match(item)) item.suspended = true; - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if (req.path === '/auth/check' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - const operation = body.operation || ''; - const allowed = !!key && roleAllows(key.role, operation); - res.status(200).send(JSON.stringify({allowed, user: key && key.user, role: key && key.role})); - return; - } - if (req.path === '/objects/read' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireRead(req, entry); - const data = await readObject(body.repo, body.path); - res.status(200).send(JSON.stringify({data})); - return; - } - if (req.path === '/objects/list' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireRead(req, entry); - const paths = await listObjects(body.repo, body.prefix); - res.status(200).send(JSON.stringify({paths})); - return; - } - if (req.path === '/refs/update' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || !roleAllows(key.role, 'write')) { - res.status(403).send(JSON.stringify({error: 'write SSH signature required'})); - return; - } - await updateRefCAS(body.repo, body.ref, body.old, body.new); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - res.status(404).send(JSON.stringify({error: 'unknown broker endpoint'})); - } catch (err) { - res.status(err.status || 500).send(JSON.stringify({error: err.message || String(err)})); - } -}; -`, - } - for name, body := range files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { - return err - } +func ensureGCPBrokerFirestoreDatabase(cfg config, opts sshSetupOptions, stdout io.Writer) error { + database := gcpBrokerFirestoreDatabase(opts) + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) + location := firstNonEmpty(strings.TrimSpace(opts.firestoreLocation), os.Getenv("BGIT_FIRESTORE_LOCATION"), region) + describe := gcloudCommand(cfg.gcloudConfiguration, + "firestore", "databases", "describe", + "--database="+database, + "--format=value(name)", + ) + if out, err := describe.Output(); err == nil && strings.TrimSpace(string(out)) != "" { + return nil + } + fmt.Fprintf(stdout, "creating Firestore database %s in %s\n", database, location) + create := gcloudCommand(cfg.gcloudConfiguration, + "firestore", "databases", "create", + "--database="+database, + "--location="+location, + "--type=firestore-native", + "--quiet", + ) + out, err := create.CombinedOutput() + if err != nil { + return fmt.Errorf("create GCP Firestore database %s in %s: %w\n%s", database, location, err, strings.TrimSpace(string(out))) } return nil } +func gcpBrokerFirestoreDatabase(opts sshSetupOptions) string { + return firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") +} + func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) (string, error) { region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) template, err := os.CreateTemp("", "bgit-aws-broker-*.yaml") @@ -1317,7 +1256,11 @@ func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( if err := template.Close(); err != nil { return "", err } - fmt.Fprintf(stdout, "deploying AWS CloudFormation stack bgit-broker in %s\n", region) + fmt.Fprintf(stdout, "deploying AWS CloudFormation stack bgit-broker in %s", region) + if strings.TrimSpace(cfg.gcloudConfiguration) != "" { + fmt.Fprintf(stdout, " with profile %s", strings.TrimSpace(cfg.gcloudConfiguration)) + } + fmt.Fprintln(stdout) args := []string{ "cloudformation", "deploy", "--stack-name", "bgit-broker", @@ -1325,348 +1268,13 @@ func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( "--capabilities", "CAPABILITY_NAMED_IAM", "--region", region, } - args = appendAWSProfile(args, strings.TrimSpace(cfg.gcloudConfiguration)) - out, err := exec.Command("aws", args...).CombinedOutput() + out, err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), args...).CombinedOutput() if err != nil { return "", fmt.Errorf("deploy AWS bgit broker: %w\n%s", err, strings.TrimSpace(string(out))) } return discoverAWSBrokerURL(cfg, opts) } -func awsBrokerCloudFormationTemplate() string { - return `AWSTemplateFormatVersion: '2010-09-09' -Description: Minimal bgit SSH broker control-plane endpoint. -Resources: - BrokerRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub bgit-broker-${AWS::Region} - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: lambda.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - Policies: - - PolicyName: bgit-broker-table - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - Resource: !GetAtt BrokerTable.Arn - - Effect: Allow - Action: - - s3:GetObject - Resource: arn:aws:s3:::*/* - - Effect: Allow - Action: - - s3:ListBucket - Resource: arn:aws:s3:::* - BrokerTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: bgit-broker-repos - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: id - AttributeType: S - KeySchema: - - AttributeName: id - KeyType: HASH - BrokerFunction: - Type: AWS::Lambda::Function - Properties: - FunctionName: bgit-broker - Runtime: nodejs22.x - Handler: index.handler - Role: !GetAtt BrokerRole.Arn - Environment: - Variables: - TABLE_NAME: !Ref BrokerTable - Code: - ZipFile: | - const crypto = require("crypto"); - const {DynamoDBClient, GetItemCommand, PutItemCommand} = require("@aws-sdk/client-dynamodb"); - const {S3Client, GetObjectCommand, ListObjectsV2Command} = require("@aws-sdk/client-s3"); - const db = new DynamoDBClient({}); - const s3 = new S3Client({}); - const table = process.env.TABLE_NAME; - function repoID(repo) { - return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); - } - function docID(repo) { - return Buffer.from(repoID(repo)).toString("base64url"); - } - async function loadRepo(repo) { - if (!repo || !repo.bucket || !repo.prefix) throw new Error("repo is required"); - const id = docID(repo); - const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); - if (!out.Item) return {id, data: {repo, keys: []}}; - const data = JSON.parse(out.Item.data.S || "{}"); - data.repo = data.repo || repo; - data.keys = data.keys || []; - return {id, data}; - } - async function saveRepo(entry) { - await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); - } - function readSSHString(buf, offset) { - const len = buf.readUInt32BE(offset); - const start = offset + 4; - return {value: buf.subarray(start, start + len), offset: start + len}; - } - function expectedMessage(rawBody) { - const digest = crypto.createHash("sha256").update(Buffer.from(rawBody || "{}")).digest("base64"); - return Buffer.from("bgit-broker-v1\n" + digest).toString("base64"); - } - function normalizeKey(key) { - return String(key || "").trim().split(/\s+/).slice(0, 2).join(" "); - } - function publicKeyObject(publicKey) { - const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== "ssh-ed25519") return crypto.createPublicKey(publicKey); - const blob = Buffer.from(parts[1], "base64"); - let parsed = readSSHString(blob, 0); - const alg = parsed.value.toString(); - if (alg !== "ssh-ed25519") throw new Error("unsupported SSH key algorithm"); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from("302a300506032b6570032100", "hex"); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: "der", type: "spki"}); - } - function header(event, name) { - const headers = event.headers || {}; - return headers[name] || headers[name.toLowerCase()] || ""; - } - function verifySignature(event, entry) { - const adminKeys = (entry.data.keys || []).filter((k) => k.role === "admin" && !k.suspended); - if (adminKeys.length === 0) return true; - const key = signedKey(event, entry); - return !!key && key.role === "admin"; - } - function signedKey(event, entry) { - const keys = (entry.data.keys || []).filter((k) => !k.suspended); - const publicKey = normalizeKey(header(event, "x-bgit-key")); - const message = String(header(event, "x-bgit-signature-message")); - const signature = String(header(event, "x-bgit-signature")); - if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; - const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); - if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, "base64"), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; - const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; - if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; - return key; - } - function roleAllows(role, operation) { - if (role === "admin") return true; - if (operation === "read") return role === "read" || role === "write"; - if (operation === "write") return role === "write"; - return false; - } - function cleanObjectPath(value) { - const path = String(value || "").replace(/^\/+/, ""); - if (path.includes("\0")) throw new Error("invalid object path"); - return path; - } - function objectName(repo, objectPath) { - const prefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); - const path = cleanObjectPath(objectPath); - return prefix ? prefix + "/" + path : path; - } - function requireRead(event, entry) { - const key = signedKey(event, entry); - if (!key || !roleAllows(key.role, "read")) { - const err = new Error("read SSH signature required"); - err.statusCode = 403; - throw err; - } - } - async function streamToBuffer(stream) { - const chunks = []; - for await (const chunk of stream) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks); - } - async function readObject(repo, objectPath) { - const out = await s3.send(new GetObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); - const data = await streamToBuffer(out.Body); - return data.toString("base64"); - } - async function listObjects(repo, prefix) { - const repoPrefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); - const queryPrefix = objectName(repo, prefix); - const paths = []; - let token = undefined; - do { - const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, Prefix: queryPrefix, ContinuationToken: token})); - for (const item of out.Contents || []) { - const strip = repoPrefix ? repoPrefix + "/" : ""; - paths.push(item.Key.startsWith(strip) ? item.Key.slice(strip.length) : item.Key); - } - token = out.NextContinuationToken; - } while (token); - return paths; - } - async function updateRefCAS(repo, ref, oldHash, newHash) { - const id = docID(repo); - const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); - const oldData = out.Item && out.Item.data ? out.Item.data.S : ""; - const data = oldData ? JSON.parse(oldData || "{}") : {repo, keys: [], refs: {}}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - data.refs = data.refs || {}; - const zero = "0000000000000000000000000000000000000000"; - const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; - if (current !== oldHash) { - const err = new Error("stale ref"); - err.statusCode = 409; - throw err; - } - if (newHash === zero) { - delete data.refs[ref]; - } else { - data.refs[ref] = newHash; - } - const item = {id: {S: id}, data: {S: JSON.stringify(data)}}; - const input = {TableName: table, Item: item}; - if (oldData) { - input.ConditionExpression = "#data = :old"; - input.ExpressionAttributeNames = {"#data": "data"}; - input.ExpressionAttributeValues = {":old": {S: oldData}}; - } else { - input.ConditionExpression = "attribute_not_exists(id)"; - } - try { - await db.send(new PutItemCommand(input)); - } catch (err) { - if (err.name === "ConditionalCheckFailedException") { - const stale = new Error("stale ref"); - stale.statusCode = 409; - throw stale; - } - throw err; - } - } - function requireAdmin(event, entry) { - if (!verifySignature(event, entry)) { - const err = new Error("admin SSH signature required"); - err.statusCode = 403; - throw err; - } - } - exports.handler = async (event) => { - const path = event.rawPath || "/"; - const method = event.requestContext && event.requestContext.http ? event.requestContext.http.method : "GET"; - const body = event.body ? JSON.parse(event.body) : {}; - try { - if (path === "/" || path === "/health") { - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true, service: "bgit-broker"}) }; - } - if (path === "/repos/upsert" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const user = body.admin_user || "admin"; - const role = body.role || "admin"; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if (path === "/keys/list" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({keys: entry.data.keys}) }; - } - if (path === "/keys/add" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const user = body.user || "admin"; - const role = body.role || "read"; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const key = String(body.key || "").trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); - if (path === "/keys/remove") { - entry.data.keys = entry.data.keys.filter((k) => !match(k)); - } else { - for (const item of entry.data.keys) if (match(item)) item.suspended = true; - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if (path === "/auth/check" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - const operation = body.operation || ""; - const allowed = !!key && roleAllows(key.role, operation); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({allowed, user: key && key.user, role: key && key.role}) }; - } - if (path === "/objects/read" && method === "POST") { - const entry = await loadRepo(body.repo); - requireRead(event, entry); - const data = await readObject(body.repo, body.path); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({data}) }; - } - if (path === "/objects/list" && method === "POST") { - const entry = await loadRepo(body.repo); - requireRead(event, entry); - const paths = await listObjects(body.repo, body.prefix); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({paths}) }; - } - if (path === "/refs/update" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || !roleAllows(key.role, "write")) { - return { statusCode: 403, headers: {"content-type": "application/json"}, body: JSON.stringify({error: "write SSH signature required"}) }; - } - await updateRefCAS(body.repo, body.ref, body.old, body.new); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - return { statusCode: 404, headers: {"content-type": "application/json"}, body: JSON.stringify({error: "unknown broker endpoint"}) }; - } catch (err) { - return { statusCode: err.statusCode || 500, headers: {"content-type": "application/json"}, body: JSON.stringify({error: err.message || String(err)}) }; - } - }; - BrokerFunctionUrl: - Type: AWS::Lambda::Url - Properties: - TargetFunctionArn: !Ref BrokerFunction - AuthType: NONE - BrokerFunctionUrlPermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref BrokerFunction - Action: lambda:InvokeFunctionUrl - Principal: '*' - FunctionUrlAuthType: NONE - BrokerFunctionInvokePermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref BrokerFunction - Action: lambda:InvokeFunction - Principal: '*' - InvokedViaFunctionUrl: true -Outputs: - BrokerUrl: - Value: !GetAtt BrokerFunctionUrl.FunctionUrl -` -} - func appendAWSProfile(args []string, profile string) []string { if strings.TrimSpace(profile) == "" { return args @@ -1674,6 +1282,17 @@ func appendAWSProfile(args []string, profile string) []string { return append(args, "--profile", strings.TrimSpace(profile)) } +func awsCommand(ctx context.Context, profile string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "aws", args...) + if strings.TrimSpace(profile) != "" { + cmd.Env = append(os.Environ(), + "AWS_PROFILE="+strings.TrimSpace(profile), + "AWS_SDK_LOAD_CONFIG=1", + ) + } + return cmd +} + func cleanBrokerURL(out string) string { value := strings.TrimSpace(out) if value == "" || value == "None" || value == "null" { diff --git a/web.go b/web.go index 489e301..27cc380 100644 --- a/web.go +++ b/web.go @@ -1,9 +1,12 @@ package main import ( + "archive/zip" "bytes" "context" + "embed" "encoding/base64" + "encoding/json" "errors" "flag" "fmt" @@ -14,17 +17,27 @@ import ( "net" "net/http" "net/url" + "os" pathpkg "path" + "path/filepath" "sort" "strconv" "strings" + "sync" "time" "unicode/utf8" ) +//go:embed www/* +var webAssets embed.FS + const ( - defaultWebAddr = "127.0.0.1" - defaultWebPort = 8042 + defaultWebAddr = "127.0.0.1" + defaultWebPort = 8042 + webPageTemplatePath = "www/page.html" + webCSSPath = "www/app.css" + webJSPath = "www/app.js" + webLogoPath = "www/bgit-mark.png" ) type webOptions struct { @@ -34,9 +47,12 @@ type webOptions struct { } type webServer struct { - repo *nativeGitRepo - cfg config - title string + repo *nativeGitRepo + apiRepo *nativeGitRepo + cfg config + title string + events *webEventHub + localGitDir string } type brokerGitStore struct { @@ -60,6 +76,12 @@ type webTreeFile struct { hash string } +type webFileIndexEntry struct { + Path string `json:"path"` + URL string `json:"url"` + Kind string `json:"kind"` +} + type webChangedFile struct { path string oldHash string @@ -67,6 +89,7 @@ type webChangedFile struct { additions int deletions int diff []webDiffLine + visual []webVisualDiffRow binary bool } @@ -81,18 +104,70 @@ type webDiffLine struct { text string } +type webAPIRef struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Kind string `json:"kind"` +} + +type webAPICommit struct { + Hash string `json:"hash"` + ShortHash string `json:"short_hash"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + Author string `json:"author"` + Email string `json:"email"` + Timestamp int64 `json:"timestamp"` + Parents []string `json:"parents,omitempty"` + Tree string `json:"tree,omitempty"` +} + +type webAPITreeEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Kind string `json:"kind"` + Hash string `json:"hash"` + URL string `json:"url"` +} + +type webAPIState struct { + Branch string `json:"branch"` + LocalHead string `json:"local_head,omitempty"` + RemoteHead string `json:"remote_head,omitempty"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Dirty bool `json:"dirty"` + DirtyFiles []string `json:"dirty_files"` + StagedFiles []string `json:"staged_files"` + UnstagedFiles []string `json:"unstaged_files"` + UntrackedFiles []string `json:"untracked_files"` + UnpushedFiles []string `json:"unpushed_files"` + UnpulledFiles []string `json:"unpulled_files"` + UnpushedCommits []webAPICommit `json:"unpushed_commits"` + UnpulledCommits []webAPICommit `json:"unpulled_commits"` + FetchError string `json:"fetch_error,omitempty"` +} + +type webPullRequestCache struct { + UpdatedAt int64 `json:"updated_at"` + PRs []brokerPullRequest `json:"prs"` +} + func webCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { opts, err := parseWebArgs(args) if err != nil { return err } - repo, closeStore, cfg, err := openWebRepository(ctx, cfg, opts.local) + repo, apiRepo, closeStore, cfg, err := openWebRepository(ctx, cfg, opts.local) if err != nil { return err } defer closeStore() - handler := newWebHandler(repo, cfg) + handler := newWebHandlerWithAPI(repo, apiRepo, cfg) + liveCtx, cancelLive := context.WithCancel(ctx) + defer cancelLive() + handler.startLiveMonitors(liveCtx) ln, err := listenWeb(opts.addr, opts.port) if err != nil { return err @@ -153,11 +228,11 @@ func parseWebArgs(args []string) (webOptions, error) { return opts, nil } -func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitRepo, func(), config, error) { +func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitRepo, *nativeGitRepo, func(), config, error) { if local { localRepo, err := openLocalRepository(".") if err != nil { - return nil, nil, cfg, err + return nil, nil, nil, cfg, err } if localCfg, err := readLocalConfig(localRepo.worktree); err == nil { cfg = mergeConfig(cfg, localCfg) @@ -165,43 +240,44 @@ func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitR if branch := localRepo.currentBranch(); branch != "" { cfg.branch = branch } - return newNativeGitRepoForStore(cfg, localRepo.store), func() {}, cfg, nil + repo := newNativeGitRepoForStore(cfg, localRepo.store) + return repo, repo, func() {}, cfg, nil } - if cfg.bucket == "" { + var seedRepo *nativeGitRepo + if localRepo, err := openLocalRepository("."); err == nil { + if localCfg, err := readLocalConfig(localRepo.worktree); err == nil { + cfg = mergeConfig(cfg, localCfg) + } + if branch := localRepo.currentBranch(); branch != "" { + cfg.branch = branch + } + seedRepo = newNativeGitRepoForStore(cfg, localRepo.store) + } + if cfg.bucket == "" && cfg.brokerURL == "" { localCfg, err := readLocalConfig(".") if err == nil { cfg = mergeConfig(cfg, localCfg) } } - if cfg.bucket == "" { - return nil, nil, cfg, errors.New("--bucket is required outside a bucketgit checkout") + if cfg.bucket == "" && cfg.brokerURL == "" { + return nil, nil, nil, cfg, errors.New("--bucket is required outside a bucketgit checkout") } - brokerURL := webBrokerURL() store, closeStore, err := newRemoteStore(ctx, cfg, true) if err != nil { - if brokerURL == "" { - return nil, nil, cfg, fmt.Errorf("create remote store: %w", err) - } - return openNativeGitRepo(&brokerGitStore{brokerURL: brokerURL, cfg: cfg}, cfg), func() {}, cfg, nil - } - if brokerURL != "" { - store = &fallbackGitRemoteStore{ - primary: store, - fallback: &brokerGitStore{brokerURL: brokerURL, cfg: cfg}, - } + return nil, nil, nil, cfg, fmt.Errorf("create remote store: %w", err) } - return openNativeGitRepo(store, cfg), closeStore, cfg, nil -} - -func webBrokerURL() string { - if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { - return strings.TrimSpace(string(out)) + remoteRepo := openNativeGitRepo(store, cfg) + if seedRepo == nil { + seedRepo = remoteRepo } - return "" + return seedRepo, remoteRepo, closeStore, cfg, nil } func (s *brokerGitStore) read(ctx context.Context, objectPath string) ([]byte, error) { + if data, ok, err := s.readWithCapability(ctx, objectPath); ok || err != nil { + return data, err + } var resp brokerObjectResponse err := brokerPostContext(ctx, s.brokerURL, "/objects/read", brokerObjectRequest{ Repo: repoForBroker(s.cfg), @@ -244,11 +320,28 @@ func isBrokerNotFoundError(err error) bool { strings.Contains(message, "not found") } -func newWebHandler(repo *nativeGitRepo, cfg config) http.Handler { - return &webServer{repo: repo, cfg: cfg, title: webRepoTitle(cfg)} +func newWebHandler(repo *nativeGitRepo, cfg config) *webServer { + return newWebHandlerWithAPI(repo, repo, cfg) +} + +func newWebHandlerWithAPI(repo, apiRepo *nativeGitRepo, cfg config) *webServer { + if apiRepo == nil { + apiRepo = repo + } + localGitDir := "" + if repo != nil { + if store, ok := repo.store.(*localGitStore); ok { + localGitDir = store.root + } + } + return &webServer{repo: repo, apiRepo: apiRepo, cfg: cfg, title: webRepoTitle(cfg), events: newWebEventHub(), localGitDir: localGitDir} } func webRepoTitle(cfg config) string { + logicalRepo := strings.Trim(cfg.logicalRepo, "/") + if logicalRepo != "" { + return logicalRepo + } if cfg.origin != "" { return cfg.origin } @@ -259,30 +352,133 @@ func webRepoTitle(cfg config) string { } func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } ctx := r.Context() route := strings.TrimPrefix(r.URL.Path, "/") + srv := s.serverForRequest(r, strings.HasPrefix(route, "api/")) switch { + case r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.HasPrefix(route, "api/actions/"): + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + case route == "assets/bgit-mark.png": + s.handleWebAsset(w, webLogoPath) + case route == "events": + s.handleEvents(w, r) + case route == "api/state": + s.handleAPIState(ctx, w, r) + case route == "api/actions/commit": + s.handleAPIActionCommit(ctx, w, r) + case route == "api/actions/stage": + s.handleAPIActionStage(ctx, w, r) + case route == "api/actions/unstage": + s.handleAPIActionUnstage(ctx, w, r) + case route == "api/actions/discard": + s.handleAPIActionDiscard(ctx, w, r) + case route == "api/actions/uncommit": + s.handleAPIActionUncommit(ctx, w, r) + case route == "api/actions/push": + s.handleAPIActionPush(ctx, w, r) + case route == "api/actions/pull": + s.handleAPIActionPull(ctx, w, r) + case route == "api/actions/pr": + s.handleAPIActionPullRequest(ctx, w, r) + case route == "api/diff": + s.handleAPIDiff(ctx, w, r) + case route == "api/refs": + srv.handleAPIRefs(ctx, w, r) + case route == "api/tree": + srv.handleAPITree(ctx, w, r) + case route == "api/commits": + srv.handleAPICommits(ctx, w, r) + case route == "api/prs": + s.handleAPIPullRequests(ctx, w, r) + case route == "api/blob": + srv.handleAPIBlob(ctx, w, r) + case strings.HasPrefix(route, "api/commit/"): + srv.handleAPICommit(ctx, w, r, strings.TrimPrefix(route, "api/commit/")) case r.URL.Path == "/": - s.handleTree(ctx, w, r, "") + srv.handleTree(ctx, w, r, "") case route == "commits": - s.handleCommits(ctx, w, r) + srv.handleCommits(ctx, w, r) + case route == "prs": + s.handlePullRequests(ctx, w, r) + case strings.HasPrefix(route, "prs/"): + s.handlePullRequest(ctx, w, r, strings.TrimPrefix(route, "prs/")) + case route == "archive.zip": + srv.handleArchiveZip(ctx, w, r) case strings.HasPrefix(route, "commit/"): - s.handleCommit(ctx, w, r, strings.TrimPrefix(route, "commit/")) + srv.handleCommit(ctx, w, r, strings.TrimPrefix(route, "commit/")) case strings.HasPrefix(route, "tree/"): - s.handleTree(ctx, w, r, strings.TrimPrefix(route, "tree/")) + srv.handleTree(ctx, w, r, strings.TrimPrefix(route, "tree/")) case strings.HasPrefix(route, "blob/"): - s.handleBlob(ctx, w, r, strings.TrimPrefix(route, "blob/"), false) + srv.handleBlob(ctx, w, r, strings.TrimPrefix(route, "blob/"), false) case strings.HasPrefix(route, "raw/"): - s.handleBlob(ctx, w, r, strings.TrimPrefix(route, "raw/"), true) + srv.handleBlob(ctx, w, r, strings.TrimPrefix(route, "raw/"), true) default: http.NotFound(w, r) } } +func (s *webServer) handleWebAsset(w http.ResponseWriter, path string) { + data, err := webAssetBytes(path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if typ := mime.TypeByExtension(filepath.Ext(path)); typ != "" { + w.Header().Set("Content-Type", typ) + } + w.Header().Set("Cache-Control", "public, max-age=86400") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (s *webServer) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + client := s.events.subscribe() + defer s.events.unsubscribe(client) + fmt.Fprint(w, "event: ready\ndata: {}\n\n") + flusher.Flush() + for { + select { + case <-r.Context().Done(): + return + case event := <-client: + fmt.Fprint(w, event) + flusher.Flush() + } + } +} + +func (s *webServer) startLiveMonitors(ctx context.Context) { + if s.events == nil { + return + } + if repo, err := openLocalRepository("."); err == nil { + go monitorWebPath(ctx, repo.gitDir, "git", s.events) + } + if dir := webAssetDir(); dir != "" { + go monitorWebPath(ctx, dir, "assets", s.events) + } +} + +func (s *webServer) serverForRequest(r *http.Request, api bool) *webServer { + if s.apiRepo == nil || s.apiRepo == s.repo { + return s + } + if api || r.URL.Query().Get("_remote") == "1" { + next := *s + next.repo = s.apiRepo + return &next + } + return s +} + func (s *webServer) headCommit(ctx context.Context, r *http.Request) (string, commitObject, string, error) { ref := strings.TrimSpace(r.URL.Query().Get("ref")) if ref == "" { @@ -299,34 +495,50 @@ func (s *webServer) headCommit(ctx context.Context, r *http.Request) (string, co return hash, commit, ref, nil } -func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string) { +func (s *webServer) handleAPIRefs(ctx context.Context, w http.ResponseWriter, r *http.Request) { + options, err := s.refOptions(ctx) + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + refs := make([]webAPIRef, 0, len(options)) + for _, option := range options { + refs = append(refs, webAPIRef{Name: option.name, FullName: option.fullName, Kind: option.kind}) + } + s.renderJSON(w, map[string]any{ + "refs": refs, + "default_ref": branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)), + }) +} + +func (s *webServer) handleAPITree(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - repoPath = cleanWebPath(repoPath) + repoPath := cleanWebPath(r.URL.Query().Get("path")) treeHash := commit.tree - if repoPath != "" { + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { hash, err := s.repo.findPath(ctx, commit.tree, repoPath) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } obj, err := s.repo.object(ctx, hash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } if obj.typ == gitObjectBlob { - http.Redirect(w, r, webURL("blob", repoPath, ref), http.StatusFound) + s.renderJSONError(w, http.StatusBadRequest, errors.New("path is a blob")) return } treeHash = hash } entries, err := s.repo.treeEntries(ctx, treeHash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } sort.SliceStable(entries, func(i, j int) bool { @@ -335,68 +547,87 @@ func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *ht } return entries[i].name < entries[j].name }) - commits, _ := s.repo.walkCommits(ctx, commit.hash, 10, 0, repoPath) - readme := s.readmeHTML(ctx, commit.tree) - - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, repoPath)) - body.WriteString(s.clonePanelHTML()) - body.WriteString(`
` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `
` + html.EscapeString(commit.author) + ` committed ` + html.EscapeString(relativeTime(commit.timestamp)) + `
` + html.EscapeString(shortHash(commit.hash)) + `
`) - body.WriteString(`
Files
`) - if repoPath != "" { - parent := pathpkg.Dir(repoPath) - if parent == "." { - parent = "" - } - body.WriteString(``) - } + apiEntries := make([]webAPITreeEntry, 0, len(entries)) for _, entry := range entries { - targetPath := pathpkg.Join(repoPath, entry.name) kind := "file" route := "blob" - name := entry.name if entry.typ == gitObjectTree { kind = "dir" route = "tree" - name += "/" } - body.WriteString(``) - } - body.WriteString(`
dir..
` + kind + `` + html.EscapeString(name) + `` + html.EscapeString(shortHash(entry.hash)) + `
`) - if readme != "" && repoPath == "" { - body.WriteString(`
README
` + readme + `
`) + targetPath := pathpkg.Join(repoPath, entry.name) + apiEntries = append(apiEntries, webAPITreeEntry{ + Name: entry.name, + Path: targetPath, + Kind: kind, + Hash: entry.hash, + URL: webURL(route, targetPath, ref), + }) } - body.WriteString(`
Recent commits
`) - body.WriteString(commitListHTML(commits, ref, true)) - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) + commits, _ := s.repo.walkCommits(ctx, commit.hash, 10, 0, repoPath) + s.renderJSON(w, map[string]any{ + "ref": ref, + "path": repoPath, + "commit": webAPICommitFromCommit(commit), + "entries": apiEntries, + "recent_commits": webAPICommitsFromCommits(commits), + }) } -func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { +func (s *webServer) handleAPICommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, "") + repoPath := cleanWebPath(r.URL.Query().Get("path")) + commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, repoPath) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, "commits")) - body.WriteString(`
Commits
`) - body.WriteString(commitListHTML(commits, ref, false)) - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, "commits"), body.String()) + s.renderJSON(w, map[string]any{"ref": ref, "path": repoPath, "head": webAPICommitFromCommit(commit), "commits": webAPICommitsFromCommits(commits)}) } -func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { +func (s *webServer) handleAPIPullRequests(ctx context.Context, w http.ResponseWriter, r *http.Request) { + refresh := r.URL.Query().Get("refresh") == "1" + prs := []brokerPullRequest{} + source := "cache" + stale := false + if !refresh { + if cached, err := s.readPullRequestCache(); err == nil { + s.renderJSON(w, map[string]any{ + "prs": webAPIPullRequests(cached.PRs), + "source": source, + "stale": true, + }) + return + } + } + refreshed, err := s.refreshPullRequestCache(ctx) + if err != nil { + cached, cacheErr := s.readPullRequestCache() + if cacheErr != nil { + s.renderJSONError(w, http.StatusForbidden, err) + return + } + prs = cached.PRs + stale = true + } else { + prs = refreshed + source = "broker" + } + s.renderJSON(w, map[string]any{ + "prs": webAPIPullRequests(prs), + "source": source, + "stale": stale, + }) +} + +func (s *webServer) handleAPICommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { hash = strings.TrimSpace(strings.Trim(hash, "/")) if hash == "" { - s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + s.renderJSONError(w, http.StatusNotFound, fs.ErrNotExist) return } commitHash, err := s.repo.resolveRevision(ctx, hash) @@ -405,243 +636,2005 @@ func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r * } commit, err := s.repo.commit(ctx, commitHash) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - ref := strings.TrimSpace(r.URL.Query().Get("ref")) files, additions, deletions, err := s.changedFiles(ctx, commit) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(firstNonEmpty(ref, commit.hash), "commit/"+shortHash(commit.hash))) - body.WriteString(`
`) - body.WriteString(`

` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

`) - if commit.body != "" { - body.WriteString(`
` + html.EscapeString(commit.body) + `
`) - } - body.WriteString(`
`) - body.WriteString(`
` + strconv.Itoa(len(files)) + ` changed file` + pluralSuffix(len(files)) + `+` + strconv.Itoa(additions) + `-` + strconv.Itoa(deletions) + `
`) - body.WriteString(``) - for _, file := range files { - body.WriteString(``) - } - body.WriteString(`
` + html.EscapeString(file.path) + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
`) - for _, file := range files { - body.WriteString(diffFileHTML(file)) - } - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, shortHash(commit.hash)), body.String()) + s.renderJSON(w, map[string]any{ + "commit": webAPICommitFromCommit(commit), + "files": webAPIChangedFiles(files), + "additions": additions, + "deletions": deletions, + }) } -func (s *webServer) handleBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string, raw bool) { +func (s *webServer) handleAPIBlob(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - repoPath = cleanWebPath(repoPath) + repoPath := cleanWebPath(r.URL.Query().Get("path")) if repoPath == "" { - s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + s.renderJSONError(w, http.StatusNotFound, fs.ErrNotExist) return } hash, err := s.repo.findPath(ctx, commit.tree, repoPath) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } obj, err := s.repo.object(ctx, hash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } if obj.typ == gitObjectTree { - http.Redirect(w, r, webURL("tree", repoPath, ref), http.StatusFound) - return - } - if raw { - contentType := mime.TypeByExtension(pathpkg.Ext(repoPath)) - if contentType == "" { - contentType = http.DetectContentType(obj.data) - } - w.Header().Set("Content-Type", contentType) - w.Write(obj.data) + s.renderJSONError(w, http.StatusBadRequest, errors.New("path is a tree")) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, repoPath)) - body.WriteString(`
` + html.EscapeString(repoPath) + `
` + strconv.Itoa(len(obj.data)) + ` bytes
Raw
`) - body.WriteString(``) + content := "" + encoding := "base64" if isTextBlob(obj.data) { - body.WriteString(`
` + html.EscapeString(string(obj.data)) + `
`) + content = string(obj.data) + encoding = "utf-8" } else { - body.WriteString(`
Binary file. Use Raw to download the contents.
`) - } - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) + content = base64.StdEncoding.EncodeToString(obj.data) + } + s.renderJSON(w, map[string]any{ + "ref": ref, + "path": repoPath, + "commit": webAPICommitFromCommit(commit), + "hash": hash, + "size": len(obj.data), + "text": isTextBlob(obj.data), + "encoding": encoding, + "content": content, + "raw_url": webURL("raw", repoPath, ref), + }) } -func (s *webServer) readmeHTML(ctx context.Context, treeHash string) string { - entries, err := s.repo.treeEntries(ctx, treeHash) +func (s *webServer) handleAPIState(ctx context.Context, w http.ResponseWriter, r *http.Request) { + state, err := s.webRepositoryState(ctx, true, r.URL.Query().Get("ref")) if err != nil { - return "" - } - for _, name := range []string{"README.md", "README", "readme.md", "readme"} { - for _, entry := range entries { - if entry.typ != gitObjectBlob || entry.name != name { - continue - } - obj, err := s.repo.object(ctx, entry.hash) - if err != nil || !isTextBlob(obj.data) { - return "" - } - return `
` + html.EscapeString(string(obj.data)) + `
` - } - } - return "" -} - -func (s *webServer) headerHTML(ref, repoPath string) string { - var b strings.Builder - b.WriteString(`
bucketgit repository

` + html.EscapeString(s.title) + `

`) - b.WriteString(s.refSelectorHTML(ref)) - b.WriteString(`
`) - b.WriteString(``) - if repoPath != "" { - b.WriteString(`
`) - b.WriteString(`root`) - current := "" - for _, part := range strings.Split(repoPath, "/") { - if part == "" { - continue - } - current = pathpkg.Join(current, part) - b.WriteString(` / ` + html.EscapeString(part) + ``) - } - b.WriteString(`
`) + s.renderJSONError(w, http.StatusInternalServerError, err) + return } - b.WriteString(`
`) - return b.String() + s.renderJSON(w, state) } -func (s *webServer) refSelectorHTML(ref string) string { - options, err := s.refOptions(context.Background()) - if err != nil || len(options) == 0 { - return `
` + html.EscapeString(displayRef(ref)) + `
` - } - selected := normalizeWebRef(ref) - if selected == "" { - selected = branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) +func (s *webServer) handleAPIDiff(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return } - var b strings.Builder - b.WriteString(``) - return b.String() -} - -func (s *webServer) refOptions(ctx context.Context) ([]webRefOption, error) { - refs, err := s.repo.refs(ctx) + diff, err := localFileDiff(repoPath, mode) if err != nil { - return nil, err + s.renderJSONError(w, http.StatusBadRequest, err) + return } - var options []webRefOption - for ref := range refs { - switch { + s.renderJSON(w, map[string]any{ + "path": repoPath, + "mode": mode, + "diff": diff, + }) + _ = ctx +} + +func (s *webServer) handleAPIActionCommit(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + req.Message = strings.TrimSpace(req.Message) + if req.Message == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("commit message is required")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + var out bytes.Buffer + if err := repo.commit([]string{"-m", req.Message}, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionStage(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("stage requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath = canonicalWorktreePath(repo, repoPath) + if err := repo.add([]string{repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionUnstage(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("unstage requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if err := repo.reset([]string{"--", repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionDiscard(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("checkout requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath = canonicalWorktreePath(repo, repoPath) + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + source := firstNonEmpty(state.RemoteHead, state.LocalHead, "HEAD") + if err := repo.checkoutPaths(source, []string{repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err = s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionUncommit(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + if state.RemoteHead == "" || state.Ahead == 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("no unpushed commits to uncommit")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if err := repo.reset([]string{"--soft", state.RemoteHead}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err = s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func canonicalWorktreePath(repo *localRepository, repoPath string) string { + files, err := repo.allWorktreeFiles() + if err != nil { + return repoPath + } + for _, file := range files { + if file == repoPath { + return file + } + } + for _, file := range files { + if strings.EqualFold(file, repoPath) { + return file + } + } + return repoPath +} + +func (s *webServer) handleAPIActionPush(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var out bytes.Buffer + if err := run([]string{"push"}, strings.NewReader("n\n"), &out, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, true, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionPull(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var out bytes.Buffer + if err := run([]string{"pull"}, strings.NewReader(""), &out, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, true, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("pull request actions require a broker-backed repository")) + return + } + var req struct { + ID int `json:"id"` + Action string `json:"action"` + Comment string `json:"comment"` + DeleteBranch bool `json:"delete_branch"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if req.ID <= 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("pull request id is required")) + return + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + brokerReq := brokerPullRequestRequest{ + Repo: repoForBroker(s.cfg), + ID: req.ID, + Comment: strings.TrimSpace(req.Comment), + DeleteBranch: req.DeleteBranch, + } + endpoint := "" + switch strings.TrimSpace(req.Action) { + case "comment": + if brokerReq.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("comment is required")) + return + } + endpoint = "/prs/comment" + case "approve": + endpoint = "/prs/review" + brokerReq.Review = "approved" + case "reject": + endpoint = "/prs/review" + brokerReq.Review = "changes_requested" + case "merge": + endpoint = "/prs/merge" + brokerReq.Merge = true + case "close": + endpoint = "/prs/close" + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported pull request action %q", req.Action)) + return + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, brokerReq, &resp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + prs := s.upsertPullRequestCache(resp.PR) + s.renderJSON(w, map[string]any{"ok": true, "pr": resp.PR, "prs": webAPIPullRequests(prs)}) +} + +func (s *webServer) webRepositoryState(ctx context.Context, refreshRemote bool, selectedRef string) (webAPIState, error) { + localRepo, err := openLocalRepository(".") + if err != nil { + return webAPIState{}, err + } + currentBranch := localRepo.currentBranch() + ref := normalizeWebRef(selectedRef) + if ref == "" { + ref = branchRef(firstNonEmpty(currentBranch, s.cfg.branch, defaultBranch)) + } + branch := shortRefName(ref) + state := webAPIState{Branch: branch} + isBranchRef := strings.HasPrefix(ref, "refs/heads/") + if refreshRemote && isBranchRef && (s.cfg.brokerURL != "" || s.cfg.bucket != "" || s.cfg.origin != "") { + if err := s.fetchWebRemoteTracking(ctx, ref); err != nil { + state.FetchError = err.Error() + } + } + if head, err := localRepo.resolveRevision(ref); err == nil { + state.LocalHead = head + } + if isBranchRef { + remoteRef := "refs/remotes/bucketgit/" + shortBranchName(ref) + if remoteHead, err := localRepo.resolveRevision(remoteRef); err == nil { + state.RemoteHead = remoteHead + } else if remoteHead, err := localRepo.resolveRevision("refs/remotes/origin/" + shortBranchName(ref)); err == nil { + state.RemoteHead = remoteHead + } + } + + if currentBranch != "" && ref == branchRef(currentBranch) { + status := localWorkingTreeStatus() + state.StagedFiles = status.staged + state.UnstagedFiles = status.unstaged + state.UntrackedFiles = status.untracked + state.DirtyFiles = append(state.DirtyFiles, state.StagedFiles...) + state.DirtyFiles = append(state.DirtyFiles, state.UnstagedFiles...) + state.DirtyFiles = append(state.DirtyFiles, state.UntrackedFiles...) + state.DirtyFiles = uniqueSortedStrings(state.DirtyFiles) + state.StagedFiles = uniqueSortedStrings(state.StagedFiles) + state.UnstagedFiles = uniqueSortedStrings(state.UnstagedFiles) + state.UntrackedFiles = uniqueSortedStrings(state.UntrackedFiles) + sort.Strings(state.DirtyFiles) + state.Dirty = len(state.DirtyFiles) > 0 + } + state.UnpushedFiles = localChangedFilesBetween(localRepo, state.RemoteHead, state.LocalHead) + state.UnpulledFiles = localChangedFilesBetween(localRepo, state.LocalHead, state.RemoteHead) + + if state.LocalHead != "" { + commits, err := localCommitRange(localRepo, state.RemoteHead, state.LocalHead, 25) + if err != nil { + return state, err + } + state.UnpushedCommits = webAPICommitsFromCommits(commits) + state.Ahead = len(commits) + } + if state.RemoteHead != "" { + commits, err := localCommitRange(localRepo, state.LocalHead, state.RemoteHead, 25) + if err != nil { + return state, err + } + state.UnpulledCommits = webAPICommitsFromCommits(commits) + state.Behind = len(commits) + } + return state, nil +} + +func (s *webServer) fetchWebRemoteTracking(ctx context.Context, branch string) error { + worktree, err := requireWorktree(".") + if err != nil { + return err + } + localCfg, err := readLocalConfig(worktree) + if err != nil { + return err + } + cfg := mergeConfig(localCfg, s.cfg) + cfg.branch = firstNonEmpty(branch, cfg.branch, defaultBranch) + store, closeStore, err := newRemoteStore(ctx, cfg, true) + if err != nil { + return err + } + defer closeStore() + repo := openNativeGitRepo(store, cfg) + return repo.fetchIntoWorktree(ctx, worktree, true, io.Discard) +} + +type webWorkingTreeStatus struct { + staged []string + unstaged []string + untracked []string +} + +func localWorkingTreeStatus() webWorkingTreeStatus { + out, err := runGit(".", "status", "--porcelain", "--untracked-files=all") + if err != nil { + return webWorkingTreeStatus{} + } + var status webWorkingTreeStatus + for _, line := range strings.Split(strings.TrimRight(string(out), "\r\n"), "\n") { + if len(line) < 4 { + continue + } + indexStatus := line[0] + worktreeStatus := line[1] + path := strings.TrimSpace(line[3:]) + if before, after, ok := strings.Cut(path, " -> "); ok && before != "" { + path = strings.TrimSpace(after) + } + path = strings.Trim(path, `"`) + if path == "" { + continue + } + if indexStatus == '?' && worktreeStatus == '?' { + status.untracked = append(status.untracked, path) + continue + } + if indexStatus != ' ' && indexStatus != '?' { + status.staged = append(status.staged, path) + } + if worktreeStatus != ' ' && worktreeStatus != '?' { + status.unstaged = append(status.unstaged, path) + } + } + return status +} + +func localFileDiff(repoPath, mode string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + repoPath = canonicalWorktreePath(repo, repoPath) + switch mode { + case "staged", "cached": + out, err := runGit(".", "diff", "--cached", "--", repoPath) + if err != nil { + return "", err + } + return string(out), nil + case "worktree", "unstaged", "": + out, err := runGit(".", "diff", "--", repoPath) + if err != nil { + return "", err + } + if len(out) > 0 { + return string(out), nil + } + status := localWorkingTreeStatus() + for _, path := range status.untracked { + if path == repoPath { + return localUntrackedFileDiff(repoPath) + } + } + return "", nil + default: + return "", fmt.Errorf("unsupported diff mode %q", mode) + } +} + +func localUntrackedFileDiff(repoPath string) (string, error) { + data, err := os.ReadFile(repoPath) + if err != nil { + return "", err + } + var b strings.Builder + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", repoPath, repoPath) + fmt.Fprintln(&b, "new file mode 100644") + fmt.Fprintln(&b, "index 0000000..0000000 100644") + fmt.Fprintln(&b, "--- /dev/null") + fmt.Fprintf(&b, "+++ b/%s\n", repoPath) + if !isTextBlob(data) { + fmt.Fprintln(&b, "Binary file changed") + return b.String(), nil + } + for _, line := range splitLines(string(data)) { + b.WriteString("+") + b.WriteString(line) + b.WriteString("\n") + } + return b.String(), nil +} + +func localCommitDiff(hash string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + hash, err = repo.resolveRevision(hash) + if err != nil { + return "", err + } + commit, err := repo.commitObject(hash) + if err != nil { + return "", err + } + if len(commit.parents) == 0 { + out, err := runGit(".", "show", "--format=", "--patch", hash) + if err != nil { + return "", err + } + return string(out), nil + } + out, err := runGit(".", "diff", commit.parents[0], hash) + if err != nil { + return "", err + } + return string(out), nil +} + +func uniqueSortedStrings(values []string) []string { + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + files := make([]string, 0, len(seen)) + for value := range seen { + files = append(files, value) + } + sort.Strings(files) + return files +} + +func localCommitRange(repo *localRepository, base, head string, limit int) ([]commitObject, error) { + if head == "" || head == base { + return nil, nil + } + excluded := map[string]struct{}{} + if base != "" { + if err := markCommitAncestors(repo, base, excluded); err != nil { + return nil, err + } + } + seen := map[string]struct{}{} + var commits []commitObject + stack := []string{head} + for len(stack) > 0 { + hash := stack[0] + stack = stack[1:] + if _, ok := seen[hash]; ok { + continue + } + seen[hash] = struct{}{} + if _, ok := excluded[hash]; ok { + continue + } + commit, err := repo.commitObject(hash) + if err != nil { + return nil, err + } + commits = append(commits, commit) + stack = append(stack, commit.parents...) + } + sort.SliceStable(commits, func(i, j int) bool { + return commits[i].timestamp > commits[j].timestamp + }) + if limit > 0 && len(commits) > limit { + commits = commits[:limit] + } + return commits, nil +} + +func markCommitAncestors(repo *localRepository, head string, out map[string]struct{}) error { + stack := []string{head} + for len(stack) > 0 { + hash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if _, ok := out[hash]; ok { + continue + } + out[hash] = struct{}{} + commit, err := repo.commitObject(hash) + if err != nil { + return err + } + stack = append(stack, commit.parents...) + } + return nil +} + +func localChangedFilesBetween(repo *localRepository, base, head string) []string { + if base == "" || head == "" || base == head { + return nil + } + before, err := repo.treeFilesForCommit(base) + if err != nil { + return nil + } + after, err := repo.treeFilesForCommit(head) + if err != nil { + return nil + } + seen := map[string]struct{}{} + for path, afterFile := range after { + if beforeFile, ok := before[path]; !ok || beforeFile.hash != afterFile.hash || beforeFile.mode != afterFile.mode { + seen[path] = struct{}{} + } + } + for path := range before { + if _, ok := after[path]; !ok { + seen[path] = struct{}{} + } + } + files := make([]string, 0, len(seen)) + for path := range seen { + files = append(files, path) + } + sort.Strings(files) + return files +} + +func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + repoPath = cleanWebPath(repoPath) + treeHash := commit.tree + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { + hash, err := s.repo.findPath(ctx, commit.tree, repoPath) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + obj, err := s.repo.object(ctx, hash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + if obj.typ == gitObjectBlob { + http.Redirect(w, r, webURL("blob", repoPath, ref), http.StatusFound) + return + } + treeHash = hash + } + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].typ != entries[j].typ { + return entries[i].typ == gitObjectTree + } + return entries[i].name < entries[j].name + }) + repoCommits, _ := s.repo.walkCommits(ctx, commit.hash, 200, 0, "") + readme := s.readmeHTML(ctx, commit.tree) + + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, repoPath)) + body.WriteString(s.repoToolbarHTML(ref, true)) + body.WriteString(s.fileIndexHTML(ctx, commit.tree, ref)) + body.WriteString(`
`) + body.WriteString(`
` + html.EscapeString(commit.author) + `` + html.EscapeString(displayCommitSubject(commit)) + `` + html.EscapeString(shortHash(commit.hash)) + `` + html.EscapeString(relativeTime(commit.timestamp)) + `
`) + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { + parent := pathpkg.Dir(repoPath) + if parent == "." { + parent = "" + } + body.WriteString(``) + } + for _, entry := range entries { + targetPath := pathpkg.Join(repoPath, entry.name) + kind := "file" + route := "blob" + name := entry.name + if entry.typ == gitObjectTree { + kind = "dir" + route = "tree" + name += "/" + } + body.WriteString(``) + } + body.WriteString(`
dir..
` + kind + `` + html.EscapeString(name) + `` + html.EscapeString(shortHash(entry.hash)) + `
`) + body.WriteString(`
README
`) + if readme != "" && repoPath == "" { + body.WriteString(readme) + } + body.WriteString(`
`) + body.WriteString(`
`) + body.WriteString(s.repoSidePanelHTML(contributorsFromCommits(repoCommits))) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) +} + +func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, "") + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "commits")) + body.WriteString(`
Commits
`) + body.WriteString(commitListHTML(commits, ref, false)) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "commits"), body.String()) +} + +func (s *webServer) handlePullRequests(ctx context.Context, w http.ResponseWriter, r *http.Request) { + prs := []brokerPullRequest{} + source := "cache" + stale := false + if cached, err := s.readPullRequestCache(); err == nil { + prs = cached.PRs + stale = true + } else if refreshed, err := s.refreshPullRequestCache(ctx); err == nil { + prs = refreshed + source = "broker" + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "prs")) + body.WriteString(`
Pull requests
`) + body.WriteString(pullRequestListHTML(prs)) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "pull requests"), body.String()) +} + +func (s *webServer) handlePullRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, value string) { + parts := strings.Split(strings.Trim(value, "/"), "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + id, err := strconv.Atoi(parts[0]) + if err != nil || id <= 0 { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + tab := "conversation" + if len(parts) > 1 && strings.TrimSpace(parts[1]) != "" { + tab = strings.TrimSpace(parts[1]) + } + pr, err := s.pullRequestByID(ctx, id) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "prs")) + body.WriteString(`
`) + body.WriteString(prHeaderHTML(pr, tab)) + switch tab { + case "files", "files-changed", "diff": + body.WriteString(s.pullRequestFilesHTML(ctx, pr)) + case "commits": + body.WriteString(s.pullRequestCommitsHTML(ctx, pr)) + default: + body.WriteString(pullRequestConversationHTML(pr)) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, fmt.Sprintf("PR #%d", pr.ID)), body.String()) +} + +func (s *webServer) pullRequestByID(ctx context.Context, id int) (brokerPullRequest, error) { + if cached, err := s.readPullRequestCache(); err == nil { + for _, pr := range cached.PRs { + if pr.ID == id { + return pr, nil + } + } + } + if refreshed, err := s.refreshPullRequestCache(ctx); err == nil { + for _, pr := range refreshed { + if pr.ID == id { + return pr, nil + } + } + } + return brokerPullRequest{}, errors.New("pull request not found") +} + +func (s *webServer) pullRequestFilesHTML(ctx context.Context, pr brokerPullRequest) string { + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if targetErr != nil || sourceErr != nil { + return `
Pull request refs are not available locally yet. Fetch the source and target branches, then refresh this page.
` + } + targetCommit, err := repo.repo.commit(ctx, targetHash) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + sourceCommit, err := repo.repo.commit(ctx, sourceHash) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + files, additions, deletions, err := repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + return diffFilesPanelHTML(files, additions, deletions) +} + +func (s *webServer) pullRequestUnifiedDiff(ctx context.Context, id int) (string, error) { + pr, err := s.pullRequestByID(ctx, id) + if err != nil { + return "", err + } + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if targetErr != nil || sourceErr != nil { + return "", errors.New("pull request refs are not available") + } + targetCommit, err := repo.repo.commit(ctx, targetHash) + if err != nil { + return "", err + } + sourceCommit, err := repo.repo.commit(ctx, sourceHash) + if err != nil { + return "", err + } + files, _, _, err := repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) + if err != nil { + return "", err + } + return changedFilesUnifiedDiff(files), nil +} + +func changedFilesUnifiedDiff(files []webChangedFile) string { + var b strings.Builder + for _, file := range files { + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", file.path, file.path) + leftShort := "0000000" + rightShort := "0000000" + if file.oldHash != "" { + leftShort = shortHash(file.oldHash) + } + if file.newHash != "" { + rightShort = shortHash(file.newHash) + } + fmt.Fprintf(&b, "index %s..%s 100644\n", leftShort, rightShort) + if file.oldHash == "" { + fmt.Fprintln(&b, "new file mode 100644") + fmt.Fprintln(&b, "--- /dev/null") + fmt.Fprintf(&b, "+++ b/%s\n", file.path) + } else if file.newHash == "" { + fmt.Fprintln(&b, "deleted file mode 100644") + fmt.Fprintf(&b, "--- a/%s\n", file.path) + fmt.Fprintln(&b, "+++ /dev/null") + } else { + fmt.Fprintf(&b, "--- a/%s\n", file.path) + fmt.Fprintf(&b, "+++ b/%s\n", file.path) + } + if file.binary { + fmt.Fprintln(&b, "Binary file changed") + continue + } + for _, line := range file.diff { + fmt.Fprintln(&b, line.text) + } + } + return b.String() +} + +func (s *webServer) pullRequestCommitsHTML(ctx context.Context, pr brokerPullRequest) string { + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if sourceErr != nil { + return `
Pull request source branch is not available.
` + } + commits, err := repo.commitRange(ctx, targetHash, sourceHash, 50) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + return `
Commits
` + commitListHTML(commits, firstNonEmpty(pr.Source, pr.Head), false) + `
` +} + +func (s *webServer) resolvePullRequestRevision(ctx context.Context, ref, fallbackHash string) (string, error) { + ref = strings.TrimSpace(ref) + var candidates []string + if ref != "" { + candidates = append(candidates, ref) + short := shortRefName(ref) + if short != "" && short != ref { + candidates = append(candidates, + short, + "refs/remotes/bucketgit/"+short, + "refs/remotes/origin/"+short, + ) + } + } + if fallbackHash != "" { + candidates = append(candidates, fallbackHash) + } + seen := map[string]struct{}{} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if hash, err := s.repo.resolveRevision(ctx, candidate); err == nil { + return hash, nil + } + } + return "", fs.ErrNotExist +} + +func (s *webServer) commitRange(ctx context.Context, base, head string, limit int) ([]commitObject, error) { + if head == "" || head == base { + return nil, nil + } + excluded := map[string]struct{}{} + if base != "" { + if err := s.markCommitAncestors(ctx, base, excluded); err != nil { + return nil, err + } + } + seen := map[string]struct{}{} + var commits []commitObject + stack := []string{head} + for len(stack) > 0 { + hash := stack[0] + stack = stack[1:] + if _, ok := seen[hash]; ok { + continue + } + seen[hash] = struct{}{} + if _, ok := excluded[hash]; ok { + continue + } + commit, err := s.repo.commit(ctx, hash) + if err != nil { + return nil, err + } + commits = append(commits, commit) + stack = append(stack, commit.parents...) + } + sort.SliceStable(commits, func(i, j int) bool { + return commits[i].timestamp > commits[j].timestamp + }) + if limit > 0 && len(commits) > limit { + commits = commits[:limit] + } + return commits, nil +} + +func (s *webServer) markCommitAncestors(ctx context.Context, head string, out map[string]struct{}) error { + stack := []string{head} + for len(stack) > 0 { + hash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if _, ok := out[hash]; ok { + continue + } + out[hash] = struct{}{} + commit, err := s.repo.commit(ctx, hash) + if err != nil { + return err + } + stack = append(stack, commit.parents...) + } + return nil +} + +func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { + hash = strings.TrimSpace(strings.Trim(hash, "/")) + if hash == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + commitHash, err := s.repo.resolveRevision(ctx, hash) + if err != nil { + commitHash = hash + } + commit, err := s.repo.commit(ctx, commitHash) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + ref := strings.TrimSpace(r.URL.Query().Get("ref")) + files, additions, deletions, err := s.changedFiles(ctx, commit) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(firstNonEmpty(ref, commit.hash), "commit/"+shortHash(commit.hash))) + body.WriteString(`
`) + body.WriteString(`

` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

`) + if commit.body != "" { + body.WriteString(`
` + html.EscapeString(commit.body) + `
`) + } + body.WriteString(`
`) + body.WriteString(diffFilesPanelHTML(files, additions, deletions)) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, shortHash(commit.hash)), body.String()) +} + +func (s *webServer) handleBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string, raw bool) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + repoPath = cleanWebPath(repoPath) + if repoPath == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + hash, err := s.repo.findPath(ctx, commit.tree, repoPath) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + obj, err := s.repo.object(ctx, hash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + if obj.typ == gitObjectTree { + http.Redirect(w, r, webURL("tree", repoPath, ref), http.StatusFound) + return + } + if raw { + contentType := mime.TypeByExtension(pathpkg.Ext(repoPath)) + if contentType == "" { + contentType = http.DetectContentType(obj.data) + } + w.Header().Set("Content-Type", contentType) + w.Write(obj.data) + return + } + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, repoPath)) + body.WriteString(`
` + html.EscapeString(repoPath) + `
` + strconv.Itoa(len(obj.data)) + ` bytes
Raw
`) + body.WriteString(``) + if isTextBlob(obj.data) { + body.WriteString(`
` + html.EscapeString(string(obj.data)) + `
`) + } else { + body.WriteString(`
Binary file. Use Raw to download the contents.
`) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) +} + +func (s *webServer) handleArchiveZip(ctx context.Context, w http.ResponseWriter, r *http.Request) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + rootName := archiveRootName(s.title, ref) + if err := s.writeZipTree(ctx, zw, commit.tree, rootName); err != nil { + _ = zw.Close() + s.renderError(w, http.StatusInternalServerError, err) + return + } + if err := zw.Close(); err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + filename := anchorID(rootName) + if filename == "" { + filename = "bucketgit" + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.zip"`) + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) +} + +func (s *webServer) writeZipTree(ctx context.Context, zw *zip.Writer, treeHash, prefix string) error { + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + return err + } + for _, entry := range entries { + target := pathpkg.Join(prefix, entry.name) + if entry.typ == gitObjectTree { + if _, err := zw.Create(target + "/"); err != nil { + return err + } + if err := s.writeZipTree(ctx, zw, entry.hash, target); err != nil { + return err + } + continue + } + obj, err := s.repo.object(ctx, entry.hash) + if err != nil { + return err + } + writer, err := zw.Create(target) + if err != nil { + return err + } + if _, err := writer.Write(obj.data); err != nil { + return err + } + } + return nil +} + +func archiveRootName(title, ref string) string { + name := strings.Trim(strings.TrimSuffix(pathpkg.Base(strings.Trim(title, "/")), ".git"), ".") + if name == "" || name == "." { + name = "bucketgit" + } + refName := displayRef(ref) + if refName == "" { + return name + } + return name + "-" + refName +} + +func (s *webServer) readmeHTML(ctx context.Context, treeHash string) string { + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + return "" + } + for _, name := range []string{"README.md", "README", "readme.md", "readme"} { + for _, entry := range entries { + if entry.typ != gitObjectBlob || entry.name != name { + continue + } + obj, err := s.repo.object(ctx, entry.hash) + if err != nil || !isTextBlob(obj.data) { + return "" + } + return `
` + html.EscapeString(string(obj.data)) + `
` + } + } + return "" +} + +func (s *webServer) headerHTML(ref, repoPath string) string { + var b strings.Builder + b.WriteString(themeToggleHTML()) + b.WriteString(`
`) + codeActive := ` class="active"` + commitsActive := "" + prsActive := "" + if repoPath == "commits" { + codeActive = "" + commitsActive = ` class="active"` + } else if repoPath == "prs" { + codeActive = "" + prsActive = ` class="active"` + } + b.WriteString(`
`) + codeActions := "" + if codeActive != "" { + codeActions = ` data-code-actions="true"` + } + b.WriteString(`
`) + if location := s.repoLocationBadge(); location != "" { + b.WriteString(`
` + html.EscapeString(location) + `
`) + } + b.WriteString(`
`) + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) repoToolbarHTML(ref string, includeSearch bool) string { + branchCount, tagCount := s.refCounts(context.Background()) + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(remoteSyncHTML()) + if includeSearch { + b.WriteString(``) + } + b.WriteString(s.codeDropdownHTML(ref)) + b.WriteString(`
`) + return b.String() +} + +type webContributor struct { + Name string +} + +func (s *webServer) repoSidePanelHTML(contributors []webContributor) string { + repoName := strings.TrimSuffix(strings.Trim(s.title, "/"), ".git") + if repoName == "" { + repoName = "this" + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + +func linkedInIconHTML() string { + return `` +} + +func gitHubIconHTML() string { + return `` +} + +func diffIconSVGHTML() string { + return `` +} + +func contributorsFromCommits(commits []commitObject) []webContributor { + indexes := map[string]int{} + contributors := []webContributor{} + for _, commit := range commits { + key := strings.TrimSpace(strings.ToLower(commit.email)) + if key == "" { + key = strings.TrimSpace(strings.ToLower(commit.author)) + } + if key == "" { + continue + } + if _, ok := indexes[key]; ok { + continue + } + name := strings.TrimSpace(commit.author) + if name == "" { + name = strings.TrimSpace(commit.email) + } + indexes[key] = len(contributors) + contributors = append(contributors, webContributor{Name: name}) + } + return contributors +} + +func (s *webServer) fileIndexHTML(ctx context.Context, treeHash, ref string) string { + files := map[string]webTreeFile{} + if err := s.collectTreeFiles(ctx, treeHash, "", files); err != nil { + return "" + } + dirs := map[string]struct{}{} + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + for dir := pathpkg.Dir(path); dir != "." && dir != ""; dir = pathpkg.Dir(dir) { + dirs[dir] = struct{}{} + } + } + sort.Strings(paths) + dirPaths := make([]string, 0, len(dirs)) + for dir := range dirs { + dirPaths = append(dirPaths, dir) + } + sort.Strings(dirPaths) + index := make([]webFileIndexEntry, 0, len(paths)+len(dirPaths)) + for _, dir := range dirPaths { + index = append(index, webFileIndexEntry{Path: dir, URL: webURL("tree", dir, ref), Kind: "dir"}) + } + for _, path := range paths { + index = append(index, webFileIndexEntry{Path: path, URL: webURL("blob", path, ref), Kind: "file"}) + } + data, err := json.Marshal(index) + if err != nil { + return "" + } + return `` +} + +func themeToggleHTML() string { + return `` +} + +func remoteSyncHTML() string { + return `
Synchronising
` +} + +func boolDataAttr(name string, value bool) string { + if !value { + return "" + } + return ` data-` + name + `="true"` +} + +func (s *webServer) repoLocationBadge() string { + brokerURL := strings.TrimSpace(s.cfg.brokerURL) + logicalRepo := strings.Trim(s.cfg.logicalRepo, "/") + if brokerURL == "" || logicalRepo == "" { + return "" + } + if parsed, err := url.Parse(brokerURL); err == nil && parsed.Host != "" { + return parsed.Host + "/" + logicalRepo + } + return strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(brokerURL, "/"), "https://"), "http://") + "/" + logicalRepo +} + +func (s *webServer) refSelectorHTML(ref string) string { + if s.repo == nil { + return `
` + html.EscapeString(displayRef(ref)) + `
` + } + options, err := s.refOptions(context.Background()) + if err != nil || len(options) == 0 { + return `
` + html.EscapeString(displayRef(ref)) + `
` + } + selected := normalizeWebRef(ref) + if selected == "" { + selected = branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + +func (s *webServer) refOptions(ctx context.Context) ([]webRefOption, error) { + refs, err := s.repo.refs(ctx) + if err != nil { + return nil, err + } + var options []webRefOption + for ref := range refs { + switch { case strings.HasPrefix(ref, "refs/heads/"): options = append(options, webRefOption{name: strings.TrimPrefix(ref, "refs/heads/"), fullName: ref, kind: "Branches"}) case strings.HasPrefix(ref, "refs/tags/"): options = append(options, webRefOption{name: strings.TrimPrefix(ref, "refs/tags/"), fullName: ref, kind: "Tags"}) } } - sort.SliceStable(options, func(i, j int) bool { - if options[i].kind != options[j].kind { - return options[i].kind < options[j].kind + sort.SliceStable(options, func(i, j int) bool { + if options[i].kind != options[j].kind { + return options[i].kind < options[j].kind + } + return options[i].name < options[j].name + }) + return options, nil +} + +func (s *webServer) refCounts(ctx context.Context) (int, int) { + options, err := s.refOptions(ctx) + if err != nil { + return 0, 0 + } + branches := 0 + tags := 0 + for _, option := range options { + switch option.kind { + case "Branches": + branches++ + case "Tags": + tags++ + } + } + return branches, tags +} + +func (s *webServer) codeDropdownHTML(ref string) string { + widget := s.cloneWidgetHTML(ref) + if widget == "" { + return "" + } + return `
` +} + +func (s *webServer) clonePanelHTML() string { + widget := s.cloneWidgetHTML("") + if widget == "" { + return "" + } + return `
` + widget + `
` +} + +func (s *webServer) cloneWidgetHTML(ref string) string { + origin := firstNonEmpty(s.cfg.origin, originForConfig(s.cfg)) + sshURL := "" + logicalRepo := strings.Trim(s.cfg.logicalRepo, "/") + if logicalRepo != "" { + sshURL = fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo) + } else if s.cfg.bucket != "" && s.cfg.prefix != "" { + sshURL = sshRemoteURL(s.cfg) + } + options := []cloneOption{} + if s.cfg.brokerURL != "" && logicalRepo != "" { + options = append(options, cloneOption{Label: "BGIT", Value: "bgit clone " + brokerCloneCommandURL(s.cfg.brokerURL, logicalRepo)}) + } else if origin != "" { + options = append(options, cloneOption{Label: "BGIT", Value: "bgit clone " + origin}) + } + if sshURL != "" { + options = append(options, cloneOption{Label: "SSH", Value: sshURL}) + } + if origin != "" && origin != sshURL { + options = append(options, cloneOption{Label: "Origin", Value: origin}) + } + if len(options) == 0 { + return "" + } + return cloneWidgetHTML(options, ref) +} + +type cloneOption struct { + Label string + Value string +} + +func cloneWidgetHTML(options []cloneOption, ref string) string { + var b strings.Builder + b.WriteString(`
Clone
`) + if len(options) > 1 { + b.WriteString(`
`) + for i, option := range options { + active := "" + selected := "false" + if i == 0 { + active = ` class="active"` + selected = "true" + } + id := anchorID("clone-" + option.Label) + b.WriteString(``) + } + b.WriteString(`
`) + } + b.WriteString(`
`) + for i, option := range options { + id := anchorID("clone-" + option.Label) + copyID := "copy-" + anchorID(option.Label+"-"+option.Value) + hidden := "" + if i != 0 { + hidden = ` hidden` + } + b.WriteString(``) + } + b.WriteString(`
`) + if ref != "" { + b.WriteString(``) + } + b.WriteString(`
`) + return b.String() +} + +func brokerCloneCommandURL(brokerURL, logicalRepo string) string { + return strings.TrimRight(strings.TrimSpace(brokerURL), "/") + "/" + strings.Trim(strings.TrimSpace(logicalRepo), "/") +} + +func (s *webServer) renderPage(w http.ResponseWriter, title, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + page := webAssetString(webPageTemplatePath) + source := "seed" + if s.apiRepo == nil || s.apiRepo == s.repo { + source = "remote" + } + page = strings.ReplaceAll(page, "{{TITLE}}", html.EscapeString(title)) + page = strings.ReplaceAll(page, "{{CSS}}", webAssetString(webCSSPath)) + page = strings.ReplaceAll(page, "{{SOURCE}}", source) + page = strings.ReplaceAll(page, "{{BODY}}", body) + page = strings.ReplaceAll(page, "{{JS}}", webAssetString(webJSPath)) + fmt.Fprint(w, page) +} + +func (s *webServer) renderError(w http.ResponseWriter, status int, err error) { + w.WriteHeader(status) + s.renderPage(w, fmt.Sprintf("%d", status), `
`+html.EscapeString(err.Error())+`
`) +} + +func (s *webServer) renderJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *webServer) renderJSONError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if encodeErr := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); encodeErr != nil { + http.Error(w, encodeErr.Error(), http.StatusInternalServerError) + } +} + +func webAPICommitFromCommit(commit commitObject) webAPICommit { + return webAPICommit{ + Hash: commit.hash, + ShortHash: shortHash(commit.hash), + Subject: firstNonEmpty(commit.subject, shortHash(commit.hash)), + Body: commit.body, + Author: commit.author, + Email: commit.email, + Timestamp: commit.timestamp, + Parents: commit.parents, + Tree: commit.tree, + } +} + +func webAPICommitsFromCommits(commits []commitObject) []webAPICommit { + out := make([]webAPICommit, 0, len(commits)) + for _, commit := range commits { + out = append(out, webAPICommitFromCommit(commit)) + } + return out +} + +func webAPIChangedFiles(files []webChangedFile) []map[string]any { + out := make([]map[string]any, 0, len(files)) + for _, file := range files { + lines := make([]map[string]string, 0, len(file.diff)) + for _, line := range file.diff { + lines = append(lines, map[string]string{"kind": line.kind, "text": line.text}) + } + out = append(out, map[string]any{ + "path": file.path, + "old_hash": file.oldHash, + "new_hash": file.newHash, + "additions": file.additions, + "deletions": file.deletions, + "binary": file.binary, + "diff": lines, + }) + } + return out +} + +func (s *webServer) pullRequestsAvailable() bool { + if strings.TrimSpace(s.cfg.brokerURL) != "" && strings.TrimSpace(s.cfg.logicalRepo) != "" { + return true + } + if cached, err := s.readPullRequestCache(); err == nil && len(cached.PRs) > 0 { + return true + } + return false +} + +func (s *webServer) pullRequestCachePath() string { + if s.localGitDir == "" { + return "" + } + return filepath.Join(s.localGitDir, "bucketgit", "cache", "prs.json") +} + +func (s *webServer) readPullRequestCache() (webPullRequestCache, error) { + path := s.pullRequestCachePath() + if path == "" { + return webPullRequestCache{}, fs.ErrNotExist + } + data, err := os.ReadFile(path) + if err != nil { + return webPullRequestCache{}, err + } + var cache webPullRequestCache + if err := json.Unmarshal(data, &cache); err != nil { + return webPullRequestCache{}, err + } + if cache.PRs == nil { + cache.PRs = []brokerPullRequest{} + } + return cache, nil +} + +func (s *webServer) writePullRequestCache(prs []brokerPullRequest) error { + path := s.pullRequestCachePath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(webPullRequestCache{UpdatedAt: time.Now().Unix(), PRs: prs}, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + return os.WriteFile(path, data, 0o644) +} + +func (s *webServer) upsertPullRequestCache(pr brokerPullRequest) []brokerPullRequest { + if pr.ID <= 0 { + if cached, err := s.readPullRequestCache(); err == nil { + return cached.PRs + } + return nil + } + cache, err := s.readPullRequestCache() + if err != nil { + cache.PRs = []brokerPullRequest{} + } + found := false + for i := range cache.PRs { + if cache.PRs[i].ID == pr.ID { + cache.PRs[i] = pr + found = true + break + } + } + if !found { + cache.PRs = append(cache.PRs, pr) + } + sort.SliceStable(cache.PRs, func(i, j int) bool { + return cache.PRs[i].ID > cache.PRs[j].ID + }) + _ = s.writePullRequestCache(cache.PRs) + return cache.PRs +} + +func (s *webServer) refreshPullRequestCache(ctx context.Context) ([]brokerPullRequest, error) { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return nil, errors.New("broker pull requests unavailable") + } + known := map[string]string{} + cached, cacheErr := s.readPullRequestCache() + if cacheErr == nil { + for _, pr := range cached.PRs { + if pr.ID > 0 && strings.TrimSpace(pr.Version) != "" { + known[strconv.Itoa(pr.ID)] = pr.Version + } } - return options[i].name < options[j].name + } + var resp struct { + PRs []brokerPullRequest `json:"prs"` + Deleted []int `json:"deleted"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/prs/sync", brokerPullRequestRequest{Repo: repoForBroker(s.cfg), Known: known}, &resp); err != nil { + if !strings.Contains(err.Error(), "unknown broker endpoint") { + return nil, err + } + if listErr := brokerPostContext(ctx, s.cfg.brokerURL, "/prs/list", brokerPullRequestRequest{Repo: repoForBroker(s.cfg)}, &resp); listErr != nil { + return nil, err + } + } + if resp.PRs == nil { + resp.PRs = []brokerPullRequest{} + } + prs := mergePullRequestCache(cached.PRs, resp.PRs, resp.Deleted) + _ = s.writePullRequestCache(prs) + return prs, nil +} + +func mergePullRequestCache(cached, changed []brokerPullRequest, deleted []int) []brokerPullRequest { + byID := map[int]brokerPullRequest{} + for _, pr := range cached { + if pr.ID > 0 { + byID[pr.ID] = pr + } + } + for _, id := range deleted { + delete(byID, id) + } + for _, pr := range changed { + if pr.ID > 0 { + byID[pr.ID] = pr + } + } + prs := make([]brokerPullRequest, 0, len(byID)) + for _, pr := range byID { + prs = append(prs, pr) + } + sort.SliceStable(prs, func(i, j int) bool { + return prs[i].ID > prs[j].ID }) - return options, nil + return prs } -func (s *webServer) clonePanelHTML() string { - origin := firstNonEmpty(s.cfg.origin, originForConfig(s.cfg)) - sshURL := "" - if s.cfg.bucket != "" && s.cfg.prefix != "" { - sshURL = sshRemoteURL(s.cfg) +func webAPIPullRequests(prs []brokerPullRequest) []map[string]any { + out := make([]map[string]any, 0, len(prs)) + for _, pr := range prs { + out = append(out, map[string]any{ + "id": pr.ID, + "title": pr.Title, + "body": pr.Body, + "source": pr.Source, + "target": pr.Target, + "status": pr.Status, + "author": pr.Author, + "approvals": pr.Approvals, + "checks": pr.Checks, + "head": pr.Head, + }) + } + return out +} + +func pullRequestListHTML(prs []brokerPullRequest) string { + if len(prs) == 0 { + return `
No pull requests found.
` } var b strings.Builder - b.WriteString(`
Clone
Use the bucket URL with bgit, or SSH after running bgit ssh setup.
`) - if origin != "" { - b.WriteString(cloneRowHTML("bgit", "bgit clone "+origin)) - b.WriteString(cloneRowHTML("origin", origin)) - } - if sshURL != "" { - b.WriteString(cloneRowHTML("ssh", sshURL)) + b.WriteString(`
    `) + for _, pr := range prs { + status := firstNonEmpty(pr.Status, "open") + title := firstNonEmpty(pr.Title, "Untitled pull request") + b.WriteString(`
  • ` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target)) + `
    ` + html.EscapeString(status) + ``) + if pr.Approvals > 0 { + b.WriteString(`` + strconv.Itoa(pr.Approvals) + ` approval`) + if pr.Approvals != 1 { + b.WriteString(`s`) + } + b.WriteString(``) + } + b.WriteString(`
  • `) } - b.WriteString(`
`) + b.WriteString(``) return b.String() } -func cloneRowHTML(label, value string) string { - id := "copy-" + anchorID(label+"-"+value) - return `
` + html.EscapeString(label) + `` + html.EscapeString(value) + `
` +func prHeaderHTML(pr brokerPullRequest, active string) string { + status := firstNonEmpty(pr.Status, "open") + title := firstNonEmpty(pr.Title, "Untitled pull request") + filesActive := "" + conversationActive := ` class="active"` + commitsActive := "" + if active == "files" || active == "files-changed" || active == "diff" { + conversationActive = "" + filesActive = ` class="active"` + } else if active == "commits" { + conversationActive = "" + commitsActive = ` class="active"` + } + id := strconv.Itoa(pr.ID) + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(status) + `

#` + id + ` ` + html.EscapeString(title) + `

`) + b.WriteString(`
` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target))) + if pr.Author != "" { + b.WriteString(` by ` + html.EscapeString(pr.Author)) + } + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`
`) + return b.String() } -func (s *webServer) renderPage(w http.ResponseWriter, title, body string) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, ``) - fmt.Fprint(w, html.EscapeString(title)) - fmt.Fprint(w, ``) - fmt.Fprint(w, body) - fmt.Fprint(w, webJS) - fmt.Fprint(w, ``) +func pullRequestConversationHTML(pr brokerPullRequest) string { + var b strings.Builder + b.WriteString(`
Conversation
`) + if strings.TrimSpace(pr.Body) != "" { + b.WriteString(`
` + html.EscapeString(pr.Body) + `
`) + } else { + b.WriteString(`
No description.
`) + } + b.WriteString(`
`) + for _, comment := range pr.Comments { + b.WriteString(prNoteHTML(comment, "commented")) + } + for _, review := range pr.Reviews { + label := "reviewed" + if review.State == "approved" { + label = "approved" + } else if review.State == "changes_requested" { + label = "requested changes" + } + b.WriteString(prNoteHTML(review, label)) + } + if len(pr.Comments) == 0 && len(pr.Reviews) == 0 { + b.WriteString(`
No comments or reviews yet.
`) + } + b.WriteString(`
`) + if firstNonEmpty(pr.Status, "open") == "open" { + b.WriteString(`
`) + } else { + b.WriteString(`
This pull request is ` + html.EscapeString(firstNonEmpty(pr.Status, "closed")) + `.
`) + } + b.WriteString(`
`) + return b.String() } -func (s *webServer) renderError(w http.ResponseWriter, status int, err error) { - w.WriteHeader(status) - s.renderPage(w, fmt.Sprintf("%d", status), `
`+html.EscapeString(err.Error())+`
`) +func prNoteHTML(note brokerPullRequestNote, action string) string { + user := firstNonEmpty(note.User, "unknown") + when := note.At + if parsed, err := time.Parse(time.RFC3339, note.At); err == nil { + when = relativeTime(parsed.Unix()) + } + var b strings.Builder + b.WriteString(`
` + html.EscapeString(user) + ` ` + html.EscapeString(action)) + if when != "" { + b.WriteString(` ` + html.EscapeString(when) + ``) + } + b.WriteString(`
`) + if strings.TrimSpace(note.Body) != "" { + b.WriteString(`
` + html.EscapeString(note.Body) + `
`) + } + b.WriteString(`
`) + return b.String() } func commitListHTML(commits []commitObject, ref string, compact bool) string { @@ -655,7 +2648,7 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { if commit.timestamp > 0 { when = time.Unix(commit.timestamp, 0).Format("2006-01-02 15:04") } - b.WriteString(`
  • ` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) + b.WriteString(`
  • ` + html.EscapeString(displayCommitSubject(commit)) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) if !compact && commit.committer != "" && (commit.committer != commit.author || commit.committerEmail != commit.email) { b.WriteString(` Ā· committed by ` + html.EscapeString(commit.committer)) } @@ -669,6 +2662,32 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { return b.String() } +func displayCommitSubject(commit commitObject) string { + const maxSubjectRunes = 80 + subject := firstNonBlankLine(commit.subject) + if subject == "" { + subject = firstNonBlankLine(commit.body) + } + if subject == "" { + return shortHash(commit.hash) + } + runes := []rune(subject) + if len(runes) <= maxSubjectRunes { + return subject + } + return strings.TrimSpace(string(runes[:maxSubjectRunes-1])) + "…" +} + +func firstNonBlankLine(value string) string { + for _, line := range strings.Split(value, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + func cleanWebPath(value string) string { value = strings.TrimSpace(strings.Trim(value, "/")) if value == "" { @@ -788,6 +2807,52 @@ func (s *webServer) changedFiles(ctx context.Context, commit commitObject) ([]we return files, totalAdditions, totalDeletions, nil } +func (s *webServer) changedFilesBetweenTrees(ctx context.Context, beforeTree, afterTree string) ([]webChangedFile, int, int, error) { + before := map[string]webTreeFile{} + if beforeTree != "" { + if err := s.collectTreeFiles(ctx, beforeTree, "", before); err != nil { + return nil, 0, 0, err + } + } + after := map[string]webTreeFile{} + if afterTree != "" { + if err := s.collectTreeFiles(ctx, afterTree, "", after); err != nil { + return nil, 0, 0, err + } + } + return s.changedFilesBetweenMaps(ctx, before, after) +} + +func (s *webServer) changedFilesBetweenMaps(ctx context.Context, before, after map[string]webTreeFile) ([]webChangedFile, int, int, error) { + seen := map[string]struct{}{} + for path := range before { + seen[path] = struct{}{} + } + for path := range after { + seen[path] = struct{}{} + } + var paths []string + for path := range seen { + if before[path].hash != after[path].hash { + paths = append(paths, path) + } + } + sort.Strings(paths) + var files []webChangedFile + totalAdditions := 0 + totalDeletions := 0 + for _, path := range paths { + file, err := s.changedFile(ctx, path, before[path].hash, after[path].hash) + if err != nil { + return nil, 0, 0, err + } + totalAdditions += file.additions + totalDeletions += file.deletions + files = append(files, file) + } + return files, totalAdditions, totalDeletions, nil +} + func (s *webServer) collectTreeFiles(ctx context.Context, treeHash, prefix string, out map[string]webTreeFile) error { entries, err := s.repo.treeEntries(ctx, treeHash) if err != nil { @@ -827,6 +2892,7 @@ func (s *webServer) changedFile(ctx context.Context, path, oldHash, newHash stri file.binary = true return file, nil } + file.visual = webVisualRowsFromText(string(oldData), string(newData), 3) for _, line := range simpleLineDiff(string(oldData), string(newData)) { diffLine := webDiffLine{text: line} switch { @@ -859,17 +2925,309 @@ func diffFileHTML(file webChangedFile) string { b.WriteString(`
    +` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) if file.binary { b.WriteString(`
    Binary file changed.
    `) + } else if len(file.visual) > 0 { + b.WriteString(webVisualDiffGridRowsHTML(file.visual)) } else { - b.WriteString(`
    `)
    -		for _, line := range file.diff {
    -			b.WriteString(`` + html.EscapeString(line.text) + ``)
    -		}
    -		b.WriteString(`
    `) + b.WriteString(webVisualDiffGridHTML(file.diff)) } b.WriteString(``) return b.String() } +type webVisualDiffRow struct { + kind string + left string + right string + oldLine string + newLine string + control string + hidden bool +} + +type webPendingDelete struct { + text string + line int +} + +func webVisualDiffGridHTML(lines []webDiffLine) string { + return webVisualDiffGridRowsHTML(webVisualDiffRows(lines)) +} + +func webVisualDiffGridRowsHTML(rows []webVisualDiffRow) string { + var b strings.Builder + b.WriteString(`
    Before
    After
    `) + for _, row := range rows { + b.WriteString(webVisualDiffRowHTML(row)) + } + b.WriteString(`
    `) + return b.String() +} + +func webVisualRowsFromText(left, right string, context int) []webVisualDiffRow { + a := splitLines(left) + b := splitLines(right) + ops := simpleLineDiffOps(a, b) + hunks := simpleLineDiffHunks(ops, context) + if len(hunks) == 0 { + return nil + } + hunkStarts := map[int]int{} + hunkEnds := map[int]int{} + visible := map[int]struct{}{} + for i, hunk := range hunks { + hunkStarts[hunk.start] = i + hunkEnds[hunk.end] = i + for i := hunk.start; i < hunk.end; i++ { + visible[i] = struct{}{} + } + } + var rows []webVisualDiffRow + var pending []webPendingDelete + flushDeletes := func(hidden bool) { + for _, deleted := range pending { + rows = append(rows, webVisualDiffRow{kind: "del", left: deleted.text, oldLine: strconv.Itoa(deleted.line), hidden: hidden}) + } + pending = nil + } + for i, op := range ops { + if hunkIndex, ok := hunkStarts[i]; ok { + hunk := hunks[hunkIndex] + flushDeletes(false) + oldStart, oldCount, newStart, newCount := simpleDiffHunkRange(ops[hunk.start:hunk.end]) + control := "" + if hunkIndex > 0 && hunks[hunkIndex-1].end < hunk.start { + control = "up" + } + rows = append(rows, webVisualDiffRow{kind: "hunk-top", left: "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount), control: control}) + } + _, isVisible := visible[i] + hidden := !isVisible + switch op.kind { + case '-': + pending = append(pending, webPendingDelete{text: op.text, line: op.oldLine}) + case '+': + if len(pending) > 0 { + deleted := pending[0] + pending = pending[1:] + rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: op.text, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(op.newLine), hidden: hidden}) + } else { + rows = append(rows, webVisualDiffRow{kind: "add", right: op.text, newLine: strconv.Itoa(op.newLine), hidden: hidden}) + } + default: + flushDeletes(hidden) + rows = append(rows, webVisualDiffRow{kind: "same", left: op.text, right: op.text, oldLine: strconv.Itoa(op.oldLine), newLine: strconv.Itoa(op.newLine), hidden: hidden}) + } + if hunkIndex, ok := hunkEnds[i+1]; ok { + hunk := hunks[hunkIndex] + flushDeletes(false) + control := "" + if hunkIndex < len(hunks)-1 && hunk.end < hunks[hunkIndex+1].start { + control = "down" + } + if control != "" { + rows = append(rows, webVisualDiffRow{kind: "hunk-bottom", control: control}) + } + } + } + flushDeletes(true) + return rows +} + +func webVisualDiffRows(lines []webDiffLine) []webVisualDiffRow { + oldLine, newLine := 0, 0 + var rows []webVisualDiffRow + var pending []webPendingDelete + flushDeletes := func() { + for _, deleted := range pending { + rows = append(rows, webVisualDiffRow{kind: "del", left: deleted.text, oldLine: strconv.Itoa(deleted.line)}) + } + pending = nil + } + for _, line := range lines { + text := line.text + switch line.kind { + case "hunk": + flushDeletes() + oldLine, newLine = webDiffHunkStarts(text) + rows = append(rows, webVisualDiffRow{kind: "hunk", left: webDiffDividerLabel(text)}) + case "del": + pending = append(pending, webPendingDelete{text: strings.TrimPrefix(text, "-"), line: oldLine}) + oldLine++ + case "add": + added := strings.TrimPrefix(text, "+") + if len(pending) > 0 { + deleted := pending[0] + pending = pending[1:] + rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: added, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(newLine)}) + } else { + rows = append(rows, webVisualDiffRow{kind: "add", right: added, newLine: strconv.Itoa(newLine)}) + } + newLine++ + default: + flushDeletes() + value := strings.TrimPrefix(text, " ") + rows = append(rows, webVisualDiffRow{kind: "same", left: value, right: value, oldLine: strconv.Itoa(oldLine), newLine: strconv.Itoa(newLine)}) + oldLine++ + newLine++ + } + } + flushDeletes() + return rows +} + +func webVisualDiffRowHTML(row webVisualDiffRow) string { + if row.kind == "hunk" || row.kind == "hunk-top" || row.kind == "hunk-bottom" || row.kind == "note" { + controls := webDiffContextControlHTML(row.control) + label := html.EscapeString(row.left) + if row.kind == "hunk" { + label = html.EscapeString(webDiffDividerLabel(row.left)) + } + if row.kind == "hunk-bottom" && label == "" { + label = `More context` + } else { + label = `` + label + `` + } + return `
    ` + controls + label + `
    ` + } + left := webDiffCellHTML(row.left, row.kind == "del", false) + right := webDiffCellHTML(row.right, false, row.kind == "add") + if row.kind == "change" { + left, right = webInlineChangedHTML(row.left, row.right) + } + hidden := "" + if row.hidden { + hidden = ` data-hidden-context="true" hidden` + } + return `` +} + +func webDiffContextControlHTML(control string) string { + switch control { + case "up": + return `` + case "down": + return `` + default: + return "" + } +} + +func webDiffCellHTML(text string, deleted, added bool) string { + if text == "" { + return "" + } + value := html.EscapeString(text) + switch { + case deleted: + return `` + value + `` + case added: + return `` + value + `` + default: + return value + } +} + +func webInlineChangedHTML(left, right string) (string, string) { + prefix := 0 + for prefix < len(left) && prefix < len(right) && left[prefix] == right[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(left)-prefix && suffix < len(right)-prefix && left[len(left)-1-suffix] == right[len(right)-1-suffix] { + suffix++ + } + oldEnd := len(left) - suffix + newEnd := len(right) - suffix + oldChanged := left[prefix:oldEnd] + newChanged := right[prefix:newEnd] + if oldChanged == "" { + oldChanged = " " + } + if newChanged == "" { + newChanged = " " + } + oldHTML := html.EscapeString(left[:prefix]) + `` + html.EscapeString(oldChanged) + `` + html.EscapeString(left[oldEnd:]) + newHTML := html.EscapeString(right[:prefix]) + `` + html.EscapeString(newChanged) + `` + html.EscapeString(right[newEnd:]) + return oldHTML, newHTML +} + +func webDiffHunkStarts(line string) (int, int) { + oldStart, _, newStart, _, ok := webDiffHunkRange(line) + if ok { + return oldStart, newStart + } + return 0, 0 +} + +func webDiffDividerLabel(line string) string { + oldStart, oldCount, newStart, newCount, ok := webDiffHunkRange(line) + if ok { + return "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount) + } + return line +} + +func webDiffHunkRange(line string) (int, int, int, int, bool) { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "@@" { + return 0, 0, 0, 0, false + } + oldStart, oldCount, ok := webParseHunkPart(fields[1], "-") + if !ok { + return 0, 0, 0, 0, false + } + newStart, newCount, ok := webParseHunkPart(fields[2], "+") + if !ok { + return 0, 0, 0, 0, false + } + return oldStart, oldCount, newStart, newCount, true +} + +func webParseHunkPart(part, prefix string) (int, int, bool) { + part = strings.TrimPrefix(part, prefix) + if part == "" { + return 0, 0, false + } + startText, countText, hasCount := strings.Cut(part, ",") + start, err := strconv.Atoi(startText) + if err != nil { + return 0, 0, false + } + count := 1 + if hasCount { + count, err = strconv.Atoi(countText) + if err != nil { + return 0, 0, false + } + } + return start, count, true +} + +func webLineRangeLabel(start, count int) string { + if count <= 1 { + return strconv.Itoa(start) + } + return strconv.Itoa(start) + "-" + strconv.Itoa(start+count-1) +} + +func diffFilesPanelHTML(files []webChangedFile, additions, deletions int) string { + var b strings.Builder + b.WriteString(`
    ` + strconv.Itoa(len(files)) + ` changed file` + pluralSuffix(len(files)) + `+` + strconv.Itoa(additions) + `-` + strconv.Itoa(deletions) + `
    `) + if len(files) == 0 { + b.WriteString(`
    No file changes.
    `) + return b.String() + } + b.WriteString(``) + for _, file := range files { + b.WriteString(``) + } + b.WriteString(`
    ` + html.EscapeString(file.path) + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) + for _, file := range files { + b.WriteString(diffFileHTML(file)) + } + return b.String() +} + func metadataItemHTML(label, name, email string, ts int64) string { var b strings.Builder b.WriteString(`
    ` + html.EscapeString(label) + `` + html.EscapeString(name) + ``) @@ -894,7 +3252,45 @@ func relativeTime(ts int64) string { if ts == 0 { return "at an unknown time" } - return time.Unix(ts, 0).Format("2006-01-02 15:04") + then := time.Unix(ts, 0) + diff := time.Since(then) + suffix := "ago" + if diff < 0 { + diff = -diff + suffix = "from now" + } + minute := time.Minute + hour := time.Hour + day := 24 * hour + week := 7 * day + month := 30 * day + year := 365 * day + switch { + case diff < minute: + return "just now" + case diff < hour: + return relativeTimeUnit(int(diff/minute), "minute", suffix) + case diff < day: + return relativeTimeUnit(int(diff/hour), "hour", suffix) + case diff < week: + return relativeTimeUnit(int(diff/day), "day", suffix) + case diff < month: + return relativeTimeUnit(int(diff/week), "week", suffix) + case diff < year: + return relativeTimeUnit(int(diff/month), "month", suffix) + default: + return relativeTimeUnit(int(diff/year), "year", suffix) + } +} + +func relativeTimeUnit(count int, unit, suffix string) string { + if count < 1 { + count = 1 + } + if count != 1 { + unit += "s" + } + return strconv.Itoa(count) + " " + unit + " " + suffix } func firstNonZero(values ...int64) int64 { @@ -926,94 +3322,132 @@ func anchorID(value string) string { return strings.Trim(b.String(), "-") } -const webCSS = ` -:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --border: color-mix(in srgb, CanvasText 16%, transparent); --muted: color-mix(in srgb, CanvasText 62%, transparent); --panel: color-mix(in srgb, Canvas 94%, CanvasText 6%); } -* { box-sizing: border-box; } -body { margin: 0; background: color-mix(in srgb, Canvas 96%, CanvasText 4%); color: CanvasText; } -a { color: #0969da; text-decoration: none; } -a:hover { text-decoration: underline; } -code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.layout { max-width: 1180px; margin: 0 auto; padding: 20px 24px 36px; } -.repo-header { border-bottom: 1px solid var(--border); margin-bottom: 16px; padding: 8px 0 0; } -.repo-topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.repo-kicker { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -h1 { font-size: 22px; line-height: 1.25; margin: 2px 0 12px; overflow-wrap: anywhere; } -h2 { font-size: 21px; line-height: 1.3; margin: 0 0 12px; } -.ref-pill { border: 1px solid var(--border); border-radius: 999px; padding: 5px 10px; font-size: 13px; background: Canvas; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ref-selector { display: grid; gap: 4px; min-width: 210px; max-width: 320px; } -.ref-selector span { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -.ref-selector select { min-height: 34px; border: 1px solid var(--border); border-radius: 6px; background: Canvas; color: CanvasText; padding: 0 32px 0 10px; font: inherit; font-size: 13px; max-width: 100%; } -.tabs { display: flex; gap: 6px; margin-top: 4px; } -.tabs a { display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-bottom: 2px solid transparent; color: CanvasText; font-weight: 600; } -.tabs a:first-child { border-bottom-color: #fd8c73; } -.crumbs { margin: 10px 0 12px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } -.panel, .clone-panel { background: Canvas; border: 1px solid var(--border); border-radius: 8px; margin: 14px 0; overflow: hidden; } -.panel-title { font-size: 14px; font-weight: 700; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); } -.clone-panel { display: grid; grid-template-columns: minmax(180px, 270px) 1fr; gap: 14px; padding: 14px; align-items: start; } -.clone-panel .panel-title { padding: 0; border: 0; background: transparent; } -.clone-grid { display: grid; gap: 8px; min-width: 0; } -.clone-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 8px; align-items: center; } -.clone-row span { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -.clone-row code { border: 1px solid var(--border); background: var(--panel); min-height: 34px; padding: 8px 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.copy-button, .button-link { border: 1px solid var(--border); background: Canvas; color: CanvasText; border-radius: 6px; min-height: 34px; padding: 0 10px; font: inherit; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } -.copy-button:hover, .button-link:hover { background: var(--panel); text-decoration: none; } -.commit-strip { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; } -.commit-subject { color: CanvasText; font-weight: 700; } -.muted, .meta { color: var(--muted); font-size: 13px; } -.files { border-collapse: collapse; width: 100%; } -.files td { border-top: 1px solid var(--border); padding: 10px 12px; vertical-align: top; } -.files tr:first-child td { border-top: 0; } -.kind { width: 54px; color: var(--muted); text-transform: uppercase; font-size: 11px; font-weight: 700; } -.hash { width: 112px; text-align: right; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } -.readme-panel .panel-title { border-bottom: 1px solid var(--border); } -.blob, .readme pre, .commit-message { margin: 0; padding: 14px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.5; background: Canvas; } -.blob-toolbar { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); } -.blob-toolbar .panel-title { padding: 0; border: 0; background: transparent; overflow-wrap: anywhere; } -.actions { display: flex; gap: 8px; align-items: center; margin: 10px 14px 14px; font-size: 13px; } -.blob-toolbar .actions { margin: 0; } -.commits { list-style: none; margin: 0; padding: 0; } -.commits li { display: flex; justify-content: space-between; gap: 16px; padding: 12px 14px; border-top: 1px solid var(--border); } -.commits li:first-child { border-top: 0; } -.commit-detail { padding: 16px; } -.commit-detail .commit-message { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 14px; background: var(--panel); } -.metadata-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.metadata-grid div { min-width: 0; } -.metadata-grid span { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 3px; } -.metadata-grid small { display: block; color: var(--muted); overflow-wrap: anywhere; } -.diff-summary { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); } -.additions { color: #1a7f37; font-weight: 700; } -.deletions { color: #cf222e; font-weight: 700; } -.changed-files .diff-stat { width: 120px; text-align: right; } -.diff-file { scroll-margin-top: 16px; } -.diff-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); overflow-wrap: anywhere; } -.diff { margin: 0; overflow: auto; font-size: 12px; line-height: 1.45; } -.diff-line { display: block; padding: 0 12px; white-space: pre; } -.diff-line.add { background: color-mix(in srgb, #2da44e 16%, Canvas); } -.diff-line.del { background: color-mix(in srgb, #cf222e 14%, Canvas); } -.diff-line.hunk { background: color-mix(in srgb, #0969da 12%, Canvas); color: #0969da; } -.empty { margin: 14px; border: 1px solid var(--border); border-radius: 6px; padding: 14px; color: var(--muted); } -.sr-only { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } -@media (max-width: 720px) { .layout { padding: 14px; } .repo-topline, .commit-strip, .blob-toolbar, .diff-header { flex-direction: column; align-items: stretch; } .ref-selector { max-width: none; } .clone-panel { grid-template-columns: 1fr; } .clone-row { grid-template-columns: 1fr; } .hash { display: none; } .commits li { flex-direction: column; gap: 6px; } .metadata-grid { grid-template-columns: 1fr; } } -` - -const webJS = `` +type webEventHub struct { + mu sync.Mutex + clients map[chan string]struct{} +} + +func newWebEventHub() *webEventHub { + return &webEventHub{clients: map[chan string]struct{}{}} +} + +func (h *webEventHub) subscribe() chan string { + ch := make(chan string, 8) + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *webEventHub) unsubscribe(ch chan string) { + h.mu.Lock() + delete(h.clients, ch) + close(ch) + h.mu.Unlock() +} + +func (h *webEventHub) broadcast(name string) { + payload := fmt.Sprintf("event: %s\ndata: {\"time\":%d}\n\n", name, time.Now().UnixMilli()) + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- payload: + default: + } + } +} + +func monitorWebPath(ctx context.Context, root, eventName string, hub *webEventHub) { + if root == "" || hub == nil { + return + } + last := webPathFingerprint(root) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + next := webPathFingerprint(root) + if next != "" && next != last { + last = next + hub.broadcast(eventName) + } + } + } +} + +func webPathFingerprint(root string) string { + var newest int64 + var count int + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if rel, relErr := filepath.Rel(root, path); relErr == nil { + slashRel := filepath.ToSlash(rel) + if strings.HasPrefix(slashRel, "refs/remotes/bucketgit/") || strings.HasPrefix(slashRel, "refs/tags/") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + } + name := entry.Name() + if entry.IsDir() { + if name == "objects" || name == "tmp" || name == "bucketgit" { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(name, ".lock") { + return nil + } + info, err := entry.Info() + if err != nil { + return nil + } + count++ + if mod := info.ModTime().UnixNano(); mod > newest { + newest = mod + } + return nil + }) + return fmt.Sprintf("%d:%d", newest, count) +} + +func webAssetString(path string) string { + data, err := webAssetBytes(path) + if err != nil { + return "" + } + return string(data) +} + +func webAssetBytes(path string) ([]byte, error) { + if diskPath := webAssetDiskPath(path); diskPath != "" { + if data, err := os.ReadFile(diskPath); err == nil { + return data, nil + } + } + return webAssets.ReadFile(path) +} + +func webAssetDiskPath(path string) string { + if _, err := os.Stat(path); err == nil { + return path + } + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), path) + if _, statErr := os.Stat(candidate); statErr == nil { + return candidate + } + } + return "" +} + +func webAssetDir() string { + return filepath.Dir(webAssetDiskPath(webJSPath)) +} From 2afaa659368584c805ec27d469f7ce812dc38605 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 00:01:54 +0200 Subject: [PATCH 02/17] Improved broker stack --- auth_capabilities.go | 468 ++++++++++++++++++++++++ auth_capabilities_test.go | 107 ++++++ broker/aws/template.yaml | 209 ++++++++++- broker/gcp/index.js | 208 ++++++++++- broker_commands.go | 109 +++++- main.go | 95 +++++ ssh.go | 127 +++++-- web.go | 727 ++++++++++++++++++++++++++++++++------ 8 files changed, 1894 insertions(+), 156 deletions(-) create mode 100644 auth_capabilities.go create mode 100644 auth_capabilities_test.go diff --git a/auth_capabilities.go b/auth_capabilities.go new file mode 100644 index 0000000..1568154 --- /dev/null +++ b/auth_capabilities.go @@ -0,0 +1,468 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type brokerAuthStatusRequest struct { + Repo brokerRepo `json:"repo"` +} + +type repoAuthCache struct { + BrokerURL string `json:"broker_url,omitempty"` + Repo string `json:"repo,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type brokerAuthStatus struct { + BrokerURL string `json:"broker_url,omitempty"` + BrokerVersion string `json:"broker_version,omitempty"` + Repo brokerRepo `json:"repo,omitempty"` + Identity brokerIdentity `json:"identity,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` + CachedAt string `json:"cached_at,omitempty"` + Stale bool `json:"stale,omitempty"` + Error string `json:"error,omitempty"` +} + +type brokerIdentity struct { + User string `json:"user,omitempty"` + Source string `json:"source,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + PublicKey string `json:"public_key,omitempty"` +} + +func whoamiCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { + jsonOut := false + refresh := false + all := false + for _, arg := range args { + switch arg { + case "--json": + jsonOut = true + case "--refresh": + refresh = true + case "--all": + all = true + default: + return errors.New("usage: bgit whoami [--json] [--refresh] [--all]") + } + } + if all { + return whoamiAllCommand(ctx, cfg, jsonOut, stdout) + } + if cfg.brokerURL == "" || cfg.logicalRepo == "" { + return errors.New("whoami requires a broker-backed BucketGit repository") + } + status, err := brokerWhoami(ctx, cfg, refresh) + if err != nil { + return err + } + if jsonOut { + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + fmt.Fprintln(stdout, string(data)) + return nil + } + fmt.Fprintf(stdout, "broker: %s\n", status.BrokerURL) + fmt.Fprintf(stdout, "repo: %s\n", firstNonEmpty(status.Repo.Logical, status.Repo.Prefix)) + fmt.Fprintf(stdout, "user: %s\n", firstNonEmpty(status.User, status.Identity.User, "unknown")) + fmt.Fprintf(stdout, "role: %s\n", firstNonEmpty(status.Role, "none")) + if status.Identity.KeyFingerprint != "" { + fmt.Fprintf(stdout, "key: %s\n", status.Identity.KeyFingerprint) + if cfg.identity != "" { + fmt.Fprintf(stdout, "selected identity: %s\n", cfg.identity) + } + } + if status.BrokerVersion != "" { + fmt.Fprintf(stdout, "broker version: %s\n", status.BrokerVersion) + } + var caps []string + for name, ok := range status.Capabilities { + if ok { + caps = append(caps, name) + } + } + sort.Strings(caps) + if len(caps) > 0 { + fmt.Fprintf(stdout, "capabilities: %s\n", strings.Join(caps, ", ")) + } + return nil +} + +func whoamiAllCommand(ctx context.Context, cfg config, jsonOut bool, stdout io.Writer) error { + if cfg.brokerURL == "" { + return errors.New("whoami --all requires a broker-backed repository or --profile selection") + } + repos, err := brokerReposMineAllKeys(ctx, cfg.brokerURL) + if err != nil { + return err + } + resp := brokerReposMineResponse{Repos: repos} + if jsonOut { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } + fmt.Fprintln(stdout, string(data)) + return nil + } + if len(resp.Repos) == 0 { + fmt.Fprintln(stdout, "No repositories found for the available SSH keys.") + return nil + } + for _, repo := range resp.Repos { + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\n", repo.Logical, repo.Role, repo.User, repo.KeyFingerprint) + } + for _, warning := range repoMembershipWarnings(resp.Repos) { + fmt.Fprintf(stdout, "warning: %s\n", warning) + } + return nil +} + +func reposCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { + jsonOut := false + if len(args) == 0 || args[0] != "mine" { + return errors.New("usage: bgit repos mine [--json]") + } + for _, arg := range args[1:] { + switch arg { + case "--json": + jsonOut = true + default: + return errors.New("usage: bgit repos mine [--json]") + } + } + return whoamiAllCommand(ctx, cfg, jsonOut, stdout) +} + +func brokerReposMineAllKeys(ctx context.Context, brokerURL string) ([]brokerRepoMembership, error) { + data := []byte(`{}`) + headerSets := brokerSignatureHeaderSetsForBroker(brokerURL, data) + if len(headerSets) == 0 { + return nil, errors.New("no SSH agent keys available") + } + merged := map[string]brokerRepoMembership{} + var lastErr error + for _, headers := range headerSets { + var resp brokerReposMineResponse + if err := brokerPostContextWithHeaders(ctx, brokerURL, "/repos/mine", data, headers, &resp); err != nil { + lastErr = err + continue + } + for _, repo := range resp.Repos { + key := repo.KeyFingerprint + "\x00" + firstNonEmpty(repo.Logical, repo.RepoID, repo.Repo.Logical) + merged[key] = repo + } + } + if len(merged) == 0 && lastErr != nil { + return nil, lastErr + } + var repos []brokerRepoMembership + for _, repo := range merged { + repos = append(repos, repo) + } + sort.Slice(repos, func(i, j int) bool { + a := firstNonEmpty(repos[i].Logical, repos[i].RepoID) + b := firstNonEmpty(repos[j].Logical, repos[j].RepoID) + if a == b { + return repos[i].KeyFingerprint < repos[j].KeyFingerprint + } + return a < b + }) + return repos, nil +} + +func repoMembershipWarnings(repos []brokerRepoMembership) []string { + byRepo := map[string][]brokerRepoMembership{} + for _, repo := range repos { + name := firstNonEmpty(repo.Logical, repo.RepoID, repo.Repo.Logical) + if name == "" { + continue + } + byRepo[name] = append(byRepo[name], repo) + } + var warnings []string + for name, memberships := range byRepo { + if len(memberships) < 2 { + continue + } + users := map[string]struct{}{} + roles := map[string]struct{}{} + for _, membership := range memberships { + users[membership.User] = struct{}{} + roles[membership.Role] = struct{}{} + } + if len(users) > 1 { + warnings = append(warnings, fmt.Sprintf("%s is available through multiple SSH keys with different user labels", name)) + } else if len(roles) > 1 { + warnings = append(warnings, fmt.Sprintf("%s is available through multiple SSH keys with different roles", name)) + } + } + sort.Strings(warnings) + return warnings +} + +func brokerPostContextWithHeaders(ctx context.Context, brokerURL, path string, data []byte, headers map[string]string, resp any) error { + endpoint := strings.TrimRight(brokerURL, "/") + path + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + httpReq.Header.Set("content-type", "application/json") + for key, value := range headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + body, readErr := io.ReadAll(httpResp.Body) + if readErr != nil { + return readErr + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = httpResp.Status + } + return fmt.Errorf("broker %s: %s", path, msg) + } + if resp != nil && len(body) > 0 { + if err := json.Unmarshal(body, resp); err != nil { + return err + } + } + return nil +} + +type brokerReposMineResponse struct { + Repos []brokerRepoMembership `json:"repos"` +} + +type brokerRepoMembership struct { + RepoID string `json:"repo_id,omitempty"` + Logical string `json:"logical,omitempty"` + Repo brokerRepo `json:"repo,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Source string `json:"source,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + Suspended bool `json:"suspended,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func brokerWhoami(ctx context.Context, cfg config, refresh bool) (brokerAuthStatus, error) { + if !refresh { + if cached, err := readWhoamiCache(cfg.brokerURL); err == nil && cached.BrokerURL != "" { + if firstNonEmpty(cached.Repo.Logical, cached.Repo.Prefix) == firstNonEmpty(cfg.logicalRepo, cfg.prefix) { + return cached, nil + } + } + } + var status brokerAuthStatus + if err := brokerPostContext(ctx, cfg.brokerURL, "/auth/status", brokerAuthStatusRequest{Repo: repoForBroker(cfg)}, &status); err != nil { + return brokerAuthStatus{}, err + } + status.BrokerURL = cfg.brokerURL + if status.Repo.Logical == "" && status.Repo.Prefix == "" { + status.Repo = repoForBroker(cfg) + } + if status.User == "" { + status.User = status.Identity.User + } + if status.CachedAt == "" { + status.CachedAt = time.Now().UTC().Format(time.RFC3339) + } + _ = writeWhoamiCache(cfg.brokerURL, status) + return status, nil +} + +func readWhoamiCache(brokerURL string) (brokerAuthStatus, error) { + path, err := whoamiCachePath(brokerURL) + if err != nil { + return brokerAuthStatus{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return brokerAuthStatus{}, err + } + var status brokerAuthStatus + if err := json.Unmarshal(data, &status); err != nil { + return brokerAuthStatus{}, err + } + return status, nil +} + +func writeWhoamiCache(brokerURL string, status brokerAuthStatus) error { + path, err := whoamiCachePath(brokerURL) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func preferredBrokerKeyFingerprints(brokerURL string, payload []byte) []string { + var preferred []string + if fp := fingerprintForIdentityPreference(brokerIdentityPreference); fp != "" { + preferred = append(preferred, fp) + } + if cache, err := readRepoAuthCache(brokerURL, payload); err == nil && cache.KeyFingerprint != "" { + preferred = append(preferred, cache.KeyFingerprint) + } + return uniqueNonEmptyStrings(preferred) +} + +func fingerprintForIdentityPreference(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if strings.HasPrefix(value, "SHA256:") { + return value + } + data, err := os.ReadFile(expandHome(value)) + if err != nil { + return "" + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return "" + } + return ssh.FingerprintSHA256(pub) +} + +func readRepoAuthCache(brokerURL string, payload []byte) (repoAuthCache, error) { + path, err := repoAuthCachePath() + if err != nil { + return repoAuthCache{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return repoAuthCache{}, err + } + var cache repoAuthCache + if err := json.Unmarshal(data, &cache); err != nil { + return repoAuthCache{}, err + } + if cache.BrokerURL != "" && brokerURL != "" && cache.BrokerURL != brokerURL { + return repoAuthCache{}, errors.New("repo auth cache is for a different broker") + } + if repo := repoNameFromBrokerPayload(payload); repo != "" && cache.Repo != "" && cache.Repo != repo { + return repoAuthCache{}, errors.New("repo auth cache is for a different repo") + } + return cache, nil +} + +func writeRepoAuthCache(brokerURL string, payload []byte, fingerprint string) error { + if strings.TrimSpace(fingerprint) == "" { + return nil + } + path, err := repoAuthCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + cache := repoAuthCache{ + BrokerURL: brokerURL, + Repo: repoNameFromBrokerPayload(payload), + KeyFingerprint: fingerprint, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func repoAuthCachePath() (string, error) { + out, err := runGit(".", "rev-parse", "--git-path", "bgit/cache/auth.json") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func repoNameFromBrokerPayload(payload []byte) string { + var raw struct { + Repo brokerRepo `json:"repo"` + } + if err := json.Unmarshal(payload, &raw); err != nil { + return "" + } + return firstNonEmpty(raw.Repo.Logical, raw.Repo.Prefix, raw.Repo.Origin) +} + +func whoamiCachePath(brokerURL string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".bgit", "cache", safeBrokerCacheName(brokerURL), "whoami.json"), nil +} + +func safeBrokerCacheName(brokerURL string) string { + value := strings.TrimSpace(brokerURL) + if parsed, err := url.Parse(value); err == nil && parsed.Host != "" { + value = parsed.Host + } + value = strings.ToLower(value) + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} + +func uniqueNonEmptyStrings(values []string) []string { + seen := map[string]struct{}{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/auth_capabilities_test.go b/auth_capabilities_test.go new file mode 100644 index 0000000..0ec5760 --- /dev/null +++ b/auth_capabilities_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWhoamiCommandWritesGlobalCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/status" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(brokerAuthStatus{ + BrokerVersion: "1.0.0-dev", + Repo: brokerRepo{Provider: "gcs", Logical: "foo.git"}, + Identity: brokerIdentity{User: "dennis", KeyFingerprint: "SHA256:test"}, + User: "dennis", + Role: "admin", + Capabilities: map[string]bool{"read": true, "push": true, "admin_keys": true}, + ResolvedAt: "2026-05-18T12:00:00Z", + }) + })) + defer server.Close() + + var stdout bytes.Buffer + cfg := config{provider: "gcs", brokerURL: server.URL, logicalRepo: "foo.git", prefix: "foo.git"} + if err := whoamiCommand(nilContext{}, cfg, []string{"--refresh"}, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "user: dennis") || !strings.Contains(stdout.String(), "role: admin") { + t.Fatalf("stdout = %q", stdout.String()) + } + path, err := whoamiCachePath(server.URL) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte(`"role": "admin"`)) { + t.Fatalf("cache = %s", data) + } + if !strings.HasPrefix(path, filepath.Join(home, ".bgit", "cache")) { + t.Fatalf("cache path = %s", path) + } +} + +func TestPreferredBrokerKeyRankUsesConfiguredThenCachedKeys(t *testing.T) { + preferred := []string{"SHA256:configured", "SHA256:cached"} + if preferredBrokerKeyRank("SHA256:configured", preferred) >= preferredBrokerKeyRank("SHA256:cached", preferred) { + t.Fatal("configured key should rank before cached key") + } + if preferredBrokerKeyRank("SHA256:cached", preferred) >= preferredBrokerKeyRank("SHA256:other", preferred) { + t.Fatal("cached key should rank before unrelated agent keys") + } +} + +func TestRepoMembershipWarningsShowAmbiguousKeys(t *testing.T) { + warnings := repoMembershipWarnings([]brokerRepoMembership{ + {Logical: "foo.git", User: "dennis", Role: "admin", KeyFingerprint: "SHA256:a"}, + {Logical: "foo.git", User: "dennis", Role: "read", KeyFingerprint: "SHA256:b"}, + {Logical: "bar.git", User: "work", Role: "read", KeyFingerprint: "SHA256:c"}, + {Logical: "bar.git", User: "personal", Role: "read", KeyFingerprint: "SHA256:d"}, + }) + got := strings.Join(warnings, "\n") + if !strings.Contains(got, "foo.git is available through multiple SSH keys with different roles") { + t.Fatalf("warnings = %#v", warnings) + } + if !strings.Contains(got, "bar.git is available through multiple SSH keys with different user labels") { + t.Fatalf("warnings = %#v", warnings) + } +} + +func TestExplicitProfileSelectionAppliesToRepositoryDiscovery(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + path := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(path, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "example-test-123456", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://gcp.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work.europe-west1", gcloudConfigurationExplicit: true} + if err := applyExplicitBrokerProfileSelection(&cfg, "repos"); err != nil { + t.Fatal(err) + } + if cfg.brokerURL != "https://gcp.example.test" || cfg.provider != "gcs" || cfg.region != "europe-west1" { + t.Fatalf("cfg = %#v", cfg) + } +} diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index bbb96a1..411cad4 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -58,9 +58,11 @@ Resources: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:Query + - dynamodb:Scan Resource: - !GetAtt BrokerTable.Arn - !GetAtt BrokerPullRequestTable.Arn + - !GetAtt BrokerMemberTable.Arn - Effect: Allow Action: - sts:AssumeRole @@ -103,6 +105,21 @@ Resources: KeyType: HASH - AttributeName: pr_id KeyType: RANGE + BrokerMemberTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-members + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: fingerprint + AttributeType: S + - AttributeName: repo_id + AttributeType: S + KeySchema: + - AttributeName: fingerprint + KeyType: HASH + - AttributeName: repo_id + KeyType: RANGE BrokerFunction: Type: AWS::Lambda::Function Properties: @@ -114,12 +131,13 @@ Resources: Variables: TABLE_NAME: !Ref BrokerTable PR_TABLE_NAME: !Ref BrokerPullRequestTable + MEMBER_TABLE_NAME: !Ref BrokerMemberTable TRANSFER_ROLE_ARN: !GetAtt BrokerTransferRole.Arn BROKER_VERSION: {{BROKER_VERSION}} Code: ZipFile: | const crypto = require("crypto"); - const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand} = require("@aws-sdk/client-dynamodb"); + const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand} = require("@aws-sdk/client-dynamodb"); const {S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadBucketCommand, CreateBucketCommand} = require("@aws-sdk/client-s3"); const {STSClient, AssumeRoleCommand} = require("@aws-sdk/client-sts"); const db = new DynamoDBClient({}); @@ -127,6 +145,7 @@ Resources: const sts = new STSClient({}); const table = process.env.TABLE_NAME; const prTable = process.env.PR_TABLE_NAME; + const memberTable = process.env.MEMBER_TABLE_NAME; const transferRoleArn = process.env.TRANSFER_ROLE_ARN; const brokerVersion = process.env.BROKER_VERSION || "{{BROKER_VERSION}}"; const zero = "0000000000000000000000000000000000000000"; @@ -163,6 +182,40 @@ Resources: } async function saveRepo(entry) { await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); + await syncMembershipIndex(entry); + } + async function syncMembershipIndex(entry) { + const repo = entry.data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + const repoIDValue = repoID(repo); + const logical = repo.logical || repo.prefix || repoIDValue; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + const fingerprint = keyFingerprint(key.public_key); + await db.send(new PutItemCommand({TableName: memberTable, Item: { + fingerprint: {S: fingerprint}, + repo_id: {S: repoIDValue}, + data: {S: JSON.stringify({repo_id: repoIDValue, logical, repo, user: key.user || "", role: key.role || "", source: key.source || "", suspended: !!key.suspended, updated_at: new Date().toISOString()})} + }})); + } + } + async function syncAllMembershipIndexes() { + let count = 0; + let ExclusiveStartKey; + do { + const out = await db.send(new ScanCommand({TableName: table, ExclusiveStartKey})); + for (const item of out.Items || []) { + const id = item.id && item.id.S || ""; + if (id === "_owners") continue; + const data = JSON.parse(item.data && item.data.S || "{}"); + const repo = data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) continue; + count++; + await syncMembershipIndex({id, data}); + } + ExclusiveStartKey = out.LastEvaluatedKey; + } while (ExclusiveStartKey); + return count; } function prKey(repoID, id) { return {repo_id: {S: repoID}, pr_id: {N: String(Number(id))}}; @@ -267,6 +320,26 @@ Resources: if (operation === "merge") return ["maintainer"].includes(role); return false; } + function keyFingerprint(publicKey) { + const parts = String(publicKey || "").trim().split(/\s+/); + const data = parts.length >= 2 ? Buffer.from(parts[1], "base64") : Buffer.from(normalizeKey(publicKey)); + return "SHA256:" + crypto.createHash("sha256").update(data).digest("base64").replace(/=+$/g, ""); + } + function roleCapabilities(role) { + return { + read: roleAllows(role, "read"), + push: roleAllows(role, "write"), + comment: ["owner", "admin", "maintainer", "developer", "triage"].includes(role), + review: ["owner", "admin", "maintainer", "developer", "triage"].includes(role), + approve: ["owner", "admin", "maintainer", "triage"].includes(role), + merge: roleAllows(role, "merge"), + admin_keys: role === "owner" || role === "admin", + manage_protection: role === "owner" || role === "admin", + reopen_pr: ["owner", "admin", "maintainer"].includes(role), + owner_transfer: role === "owner", + broker_upgrade: role === "owner" || role === "admin", + }; + } function validRole(role) { return ["owner", "admin", "maintainer", "developer", "triage", "read"].includes(role); } @@ -390,6 +463,62 @@ Resources: pr.next_note_id = Number(pr.next_note_id || 1); return pr.next_note_id++; } + function nextPRCommentID(pr) { + pr.next_comment_id = Number(pr.next_comment_id || 1); + return pr.next_comment_id++; + } + function hashLineText(value) { + return crypto.createHash("sha1").update(String(value || "")).digest("hex"); + } + function normalizeReviewComments(pr, comments, key, head) { + if (!Array.isArray(comments)) return []; + const now = new Date().toISOString(); + return comments.map((comment) => { + const body = String(comment.body || "").trim(); + if (!body) return null; + const lineText = String(comment.line_text || ""); + return { + id: nextPRCommentID(pr), + user: key.user, + body, + file: String(comment.file || "").trim(), + kind: String(comment.kind || "line").trim(), + side: String(comment.side || "new").trim(), + hunk: String(comment.hunk || "").trim(), + hunk_index: Number(comment.hunk_index || 0), + old_start: Number(comment.old_start || 0), + new_start: Number(comment.new_start || 0), + offset: Number(comment.offset || 0), + line: Number(comment.line || 0), + line_text: lineText, + line_hash: String(comment.line_hash || hashLineText(lineText)), + head: String(comment.head || head || pr.head || ""), + at: now, + }; + }).filter(Boolean); + } + function findPRComment(comments, id) { + if (!Array.isArray(comments) || !id) return null; + for (const comment of comments) { + if (Number(comment.id) === Number(id)) return comment; + const nested = findPRComment(comment.replies || [], id); + if (nested) return nested; + } + return null; + } + function findPRReplyTarget(pr, noteID, commentID) { + const notes = [...(pr.comments || []), ...(pr.reviews || [])]; + for (const note of notes) { + if (commentID) { + const inline = findPRComment(note.comments || [], commentID); + if (inline) return inline; + const reply = findPRComment(note.replies || [], commentID); + if (reply) return reply; + } + if (noteID && Number(note.id) === Number(noteID)) return note; + } + return null; + } function bumpPRVersion(data, pr) { const now = new Date().toISOString(); data.next_pr_version = Number(data.next_pr_version || 1); @@ -603,6 +732,20 @@ Resources: await savePR(entry, pr); return response(200, {ok: true, pr}); } + if (path === "/prs/reopen" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + pr.status = "open"; + delete pr.closed_by; + delete pr.closed_at; + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_reopen", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } if (path === "/prs/comment" && method === "POST") { const entry = await loadRepo(body.repo); const key = requireOperation(event, entry, "read"); @@ -618,15 +761,33 @@ Resources: await savePR(entry, pr); return response(200, {ok: true, pr}); } + if (path === "/prs/reply" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw Object.assign(new Error("comment is required"), {statusCode: 400}); + const target = findPRReplyTarget(pr, Number(body.target_note_id || 0), Number(body.target_comment_id || 0)); + if (!target) throw Object.assign(new Error("reply target not found"), {statusCode: 404}); + target.replies = target.replies || []; + target.replies.push({id: nextPRCommentID(pr), user: key.user, body: comment, kind: "reply", at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_reply", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } if (path === "/prs/review" && method === "POST") { const entry = await loadRepo(body.repo); const key = requireOperation(event, entry, "write"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); const state = String(body.review || "").trim(); - if (!["approved", "changes_requested"].includes(state)) throw Object.assign(new Error("unsupported review state"), {statusCode: 400}); + if (!["commented", "approved", "changes_requested"].includes(state)) throw Object.assign(new Error("unsupported review state"), {statusCode: 400}); pr.reviews = pr.reviews || []; - pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || "").trim(), state, at: new Date().toISOString()}); + const comments = normalizeReviewComments(pr, body.comments, key, pr.head); + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || "").trim(), state, comments, head: String(pr.head || ""), at: new Date().toISOString()}); pr.approvals = countApprovals(pr); bumpPRVersion(entry.data, pr); audit(entry, {type: "pr_review", id: pr.id, user: key.user, state}); @@ -668,6 +829,48 @@ Resources: const allowed = !!key && roleAllows(key.role, operation); return response(200, {allowed, user: key && key.user, role: key && key.role}); } + if (path === "/auth/status" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + return response(200, { + broker_version: brokerVersion, + repo: entry.data.repo || body.repo, + identity: {user: key.user || "", source: key.source || "", key_fingerprint: keyFingerprint(key.public_key), public_key: key.public_key || ""}, + user: key.user || "", + role: key.role || "", + capabilities: roleCapabilities(key.role || ""), + resolved_at: new Date().toISOString(), + }); + } + if (path === "/repos/mine" && method === "POST") { + const fingerprint = header(event, "x-bgit-key-fingerprint") || ""; + if (!fingerprint) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const out = await db.send(new QueryCommand({ + TableName: memberTable, + KeyConditionExpression: "fingerprint = :fingerprint", + ExpressionAttributeValues: {":fingerprint": {S: fingerprint}} + })); + const repos = []; + for (const item of out.Items || []) { + const data = JSON.parse(item.data.S || "{}"); + if (!data.suspended && data.repo && (data.repo.logical || (data.repo.bucket && data.repo.prefix))) repos.push({...data, key_fingerprint: fingerprint}); + } + repos.sort((a, b) => String(a.logical || a.repo_id || "").localeCompare(String(b.logical || b.repo_id || ""))); + return response(200, {repos}); + } + if (path === "/members/reindex" && method === "POST") { + if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + await syncMembershipIndex(entry); + return response(200, {ok: true, repositories: 1}); + } + const owners = await loadOwners(); + if (!verifySignature(event, owners)) throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const count = await syncAllMembershipIndexes(); + return response(200, {ok: true, repositories: count}); + } if (path === "/objects/capability" && method === "POST") { const entry = await loadRepo(body.repo); const operation = body.operation || "read"; diff --git a/broker/gcp/index.js b/broker/gcp/index.js index 06f20d8..6c2dc6d 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -7,6 +7,7 @@ const {GoogleAuth} = require('google-auth-library'); const db = new Firestore({databaseId: process.env.FIRESTORE_DATABASE || 'bgit'}); const repos = db.collection('bgit_broker_repos'); +const members = db.collection('bgit_broker_members'); const storage = new Storage(); const auth = new GoogleAuth({scopes: ['https://www.googleapis.com/auth/cloud-platform']}); const brokerVersion = process.env.BROKER_VERSION || '{{BROKER_VERSION}}'; @@ -42,6 +43,46 @@ async function loadRepo(repo) { async function saveRepo(entry) { await entry.ref.set(entry.data, {merge: true}); + await syncMembershipIndex(entry); +} + +async function syncMembershipIndex(entry) { + const repo = entry.data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + const repoIDValue = repoID(repo); + const logical = repo.logical || repo.prefix || repoIDValue; + const writes = []; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + const fingerprint = keyFingerprint(key.public_key); + writes.push(members.doc(memberDocID(fingerprint)).collection('repos').doc(docID(repo)).set({ + repo_id: repoIDValue, + logical, + repo, + user: key.user || '', + role: key.role || '', + source: key.source || '', + suspended: !!key.suspended, + updated_at: new Date().toISOString(), + }, {merge: true})); + } + await Promise.all(writes); +} + +async function syncAllMembershipIndexes() { + const snap = await repos.get(); + const writes = []; + let count = 0; + snap.forEach((doc) => { + if (doc.id === '_owners') return; + const data = doc.data() || {}; + const repo = data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + count++; + writes.push(syncMembershipIndex({ref: doc.ref, data})); + }); + await Promise.all(writes); + return count; } function prDoc(entry, id) { @@ -154,6 +195,32 @@ function roleAllows(role, operation) { return false; } +function keyFingerprint(publicKey) { + const parts = String(publicKey || '').trim().split(/\s+/); + const data = parts.length >= 2 ? Buffer.from(parts[1], 'base64') : Buffer.from(normalizeKey(publicKey)); + return 'SHA256:' + crypto.createHash('sha256').update(data).digest('base64').replace(/=+$/g, ''); +} + +function memberDocID(fingerprint) { + return Buffer.from(String(fingerprint || '')).toString('base64url'); +} + +function roleCapabilities(role) { + return { + read: roleAllows(role, 'read'), + push: roleAllows(role, 'write'), + comment: ['owner', 'admin', 'maintainer', 'developer', 'triage'].includes(role), + review: ['owner', 'admin', 'maintainer', 'developer', 'triage'].includes(role), + approve: ['owner', 'admin', 'maintainer', 'triage'].includes(role), + merge: roleAllows(role, 'merge'), + admin_keys: role === 'owner' || role === 'admin', + manage_protection: role === 'owner' || role === 'admin', + reopen_pr: ['owner', 'admin', 'maintainer'].includes(role), + owner_transfer: role === 'owner', + broker_upgrade: role === 'owner' || role === 'admin', + }; +} + function validRole(role) { return ['owner', 'admin', 'maintainer', 'developer', 'triage', 'read'].includes(role); } @@ -323,6 +390,67 @@ function nextPRNoteID(pr) { return pr.next_note_id++; } +function nextPRCommentID(pr) { + pr.next_comment_id = Number(pr.next_comment_id || 1); + return pr.next_comment_id++; +} + +function hashLineText(value) { + return crypto.createHash('sha1').update(String(value || '')).digest('hex'); +} + +function normalizeReviewComments(pr, comments, key, head) { + if (!Array.isArray(comments)) return []; + const now = new Date().toISOString(); + return comments.map((comment) => { + const body = String(comment.body || '').trim(); + if (!body) return null; + const lineText = String(comment.line_text || ''); + return { + id: nextPRCommentID(pr), + user: key.user, + body, + file: String(comment.file || '').trim(), + kind: String(comment.kind || 'line').trim(), + side: String(comment.side || 'new').trim(), + hunk: String(comment.hunk || '').trim(), + hunk_index: Number(comment.hunk_index || 0), + old_start: Number(comment.old_start || 0), + new_start: Number(comment.new_start || 0), + offset: Number(comment.offset || 0), + line: Number(comment.line || 0), + line_text: lineText, + line_hash: String(comment.line_hash || hashLineText(lineText)), + head: String(comment.head || head || pr.head || ''), + at: now, + }; + }).filter(Boolean); +} + +function findPRComment(comments, id) { + if (!Array.isArray(comments) || !id) return null; + for (const comment of comments) { + if (Number(comment.id) === Number(id)) return comment; + const nested = findPRComment(comment.replies || [], id); + if (nested) return nested; + } + return null; +} + +function findPRReplyTarget(pr, noteID, commentID) { + const notes = [...(pr.comments || []), ...(pr.reviews || [])]; + for (const note of notes) { + if (commentID) { + const inline = findPRComment(note.comments || [], commentID); + if (inline) return inline; + const reply = findPRComment(note.replies || [], commentID); + if (reply) return reply; + } + if (noteID && Number(note.id) === Number(noteID)) return note; + } + return null; +} + function bumpPRVersion(data, pr) { const now = new Date().toISOString(); data.next_pr_version = Number(data.next_pr_version || 1); @@ -537,6 +665,21 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true, pr})); return; } + if (req.path === '/prs/reopen' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + pr.status = 'open'; + delete pr.closed_by; + delete pr.closed_at; + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_reopen', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } if (req.path === '/prs/comment' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const key = requireRead(req, entry); @@ -553,15 +696,34 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true, pr})); return; } + if (req.path === '/prs/reply' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw Object.assign(new Error('comment is required'), {status: 400}); + const target = findPRReplyTarget(pr, Number(body.target_note_id || 0), Number(body.target_comment_id || 0)); + if (!target) throw Object.assign(new Error('reply target not found'), {status: 404}); + target.replies = target.replies || []; + target.replies.push({id: nextPRCommentID(pr), user: key.user, body: comment, kind: 'reply', at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_reply', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } if (req.path === '/prs/review' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const key = requireWrite(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); const state = String(body.review || '').trim(); - if (!['approved', 'changes_requested'].includes(state)) throw Object.assign(new Error('unsupported review state'), {status: 400}); + if (!['commented', 'approved', 'changes_requested'].includes(state)) throw Object.assign(new Error('unsupported review state'), {status: 400}); pr.reviews = pr.reviews || []; - pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || '').trim(), state, at: new Date().toISOString()}); + const comments = normalizeReviewComments(pr, body.comments, key, pr.head); + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || '').trim(), state, comments, head: String(pr.head || ''), at: new Date().toISOString()}); pr.approvals = countApprovals(pr); bumpPRVersion(entry.data, pr); audit(entry, {type: 'pr_review', id: pr.id, user: key.user, state}); @@ -607,6 +769,48 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({allowed, user: key && key.user, role: key && key.role})); return; } + if (req.path === '/auth/status' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key) throw Object.assign(new Error('SSH signature required'), {status: 403}); + res.status(200).send(JSON.stringify({ + broker_version: brokerVersion, + repo: entry.data.repo || body.repo, + identity: {user: key.user || '', source: key.source || '', key_fingerprint: keyFingerprint(key.public_key), public_key: key.public_key || ''}, + user: key.user || '', + role: key.role || '', + capabilities: roleCapabilities(key.role || ''), + resolved_at: new Date().toISOString(), + })); + return; + } + if (req.path === '/repos/mine' && req.method === 'POST') { + const fingerprint = req.get('x-bgit-key-fingerprint') || ''; + if (!fingerprint) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const snap = await members.doc(memberDocID(fingerprint)).collection('repos').get(); + const out = []; + snap.forEach((doc) => { + const item = doc.data() || {}; + if (!item.suspended && item.repo && (item.repo.logical || (item.repo.bucket && item.repo.prefix))) out.push({...item, key_fingerprint: fingerprint}); + }); + out.sort((a, b) => String(a.logical || a.repo_id || '').localeCompare(String(b.logical || b.repo_id || ''))); + res.status(200).send(JSON.stringify({repos: out})); + return; + } + if (req.path === '/members/reindex' && req.method === 'POST') { + if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + await syncMembershipIndex(entry); + res.status(200).send(JSON.stringify({ok: true, repositories: 1})); + return; + } + const owners = await loadOwners(); + if (!verifySignature(req, owners)) throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const count = await syncAllMembershipIndexes(); + res.status(200).send(JSON.stringify({ok: true, repositories: count})); + return; + } if (req.path === '/objects/capability' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const operation = body.operation || 'read'; diff --git a/broker_commands.go b/broker_commands.go index ee483fc..28e6611 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -28,7 +28,7 @@ func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit admin keys|owner|protect [args]\n\nCloud IAM administration moved to bgit direct admin.") + return errors.New("usage: bgit admin keys|owner|protect|members [args]\n\nCloud IAM administration moved to bgit direct admin.") } switch args[0] { case "keys": @@ -39,6 +39,8 @@ func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, std return brokerOwnerCommand(cfg, args[1:], stdout) case "protect": return brokerProtectionCommand(cfg, args[1:], stdout) + case "members": + return brokerMembersCommand(cfg, args[1:], stdout) case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": return errors.New("cloud IAM administration moved to bgit direct admin") default: @@ -46,6 +48,44 @@ func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, std } } +func brokerMembersCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) != 1 || args[0] != "reindex" { + return errors.New("usage: bgit admin members reindex") + } + return janitorMembersReindex(cfg, stdout) +} + +func janitorCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit janitor members reindex") + } + switch args[0] { + case "members": + if len(args) == 2 && args[1] == "reindex" { + return janitorMembersReindex(cfg, stdout) + } + return errors.New("usage: bgit janitor members reindex") + default: + return fmt.Errorf("unknown janitor command %q", args[0]) + } +} + +func janitorMembersReindex(cfg config, stdout io.Writer) error { + brokerURL := strings.TrimSpace(cfg.brokerURL) + if brokerURL == "" { + var err error + brokerURL, err = brokerURLForCommand(sshSetupOptions{}) + if err != nil { + return err + } + } + if err := brokerPost(brokerURL, "/members/reindex", brokerKeyRequest{Repo: repoForBroker(cfg)}, nil); err != nil { + return err + } + fmt.Fprintln(stdout, "reindexed broker membership") + return nil +} + func brokerInitCommand(args []string, stdin io.Reader, stdout io.Writer) error { opts, repoName, err := parseBrokerInitArgs(args) if err != nil { @@ -470,29 +510,56 @@ type brokerPullRequest struct { } type brokerPullRequestNote struct { - ID int `json:"id,omitempty"` - User string `json:"user,omitempty"` - Body string `json:"body,omitempty"` - State string `json:"state,omitempty"` - Source string `json:"source,omitempty"` - At string `json:"at,omitempty"` + ID int `json:"id,omitempty"` + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + State string `json:"state,omitempty"` + Source string `json:"source,omitempty"` + At string `json:"at,omitempty"` + Comments []brokerPullRequestComment `json:"comments,omitempty"` + Replies []brokerPullRequestComment `json:"replies,omitempty"` + Head string `json:"head,omitempty"` +} + +type brokerPullRequestComment struct { + ID int `json:"id,omitempty"` + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + File string `json:"file,omitempty"` + Kind string `json:"kind,omitempty"` + Side string `json:"side,omitempty"` + Hunk string `json:"hunk,omitempty"` + HunkIndex int `json:"hunk_index,omitempty"` + OldStart int `json:"old_start,omitempty"` + NewStart int `json:"new_start,omitempty"` + Offset int `json:"offset,omitempty"` + Line int `json:"line,omitempty"` + LineText string `json:"line_text,omitempty"` + LineHash string `json:"line_hash,omitempty"` + Head string `json:"head,omitempty"` + Outdated bool `json:"outdated,omitempty"` + At string `json:"at,omitempty"` + Replies []brokerPullRequestComment `json:"replies,omitempty"` } type brokerPullRequestRequest struct { - Repo brokerRepo `json:"repo"` - ID int `json:"id,omitempty"` - PR brokerPullRequest `json:"pr,omitempty"` - Known map[string]string `json:"known,omitempty"` - Merge bool `json:"merge,omitempty"` - DeleteBranch bool `json:"delete_branch,omitempty"` - Comment string `json:"comment,omitempty"` - Review string `json:"review,omitempty"` + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + PR brokerPullRequest `json:"pr,omitempty"` + Known map[string]string `json:"known,omitempty"` + Merge bool `json:"merge,omitempty"` + DeleteBranch bool `json:"delete_branch,omitempty"` + Comment string `json:"comment,omitempty"` + Review string `json:"review,omitempty"` + Comments []brokerPullRequestComment `json:"comments,omitempty"` + TargetNoteID int `json:"target_note_id,omitempty"` + TargetCommentID int `json:"target_comment_id,omitempty"` } func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { _ = stdin if len(args) == 0 { - return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close [args]") + return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close|reopen [args]") } cfg, err := configForBrokerCommand(config{}) if err != nil { @@ -532,6 +599,16 @@ func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { } fmt.Fprintf(stdout, "closed PR #%d\n", id) return nil + case "reopen": + id, err := parsePRIDArg(args[1:]) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/reopen", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "reopened PR #%d\n", id) + return nil case "merge": id, err := parsePRIDArg(args[1:]) if err != nil { diff --git a/main.go b/main.go index 614b005..366393f 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ type config struct { region string auth string gcloudConfiguration string + identity string direct bool authExplicit bool gcloudConfigurationExplicit bool @@ -60,6 +61,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } return usage(stderr) } + setBrokerIdentityPreference(cfg.identity) if cfg.versionRequested { return versionCommand(stdout) } @@ -90,8 +92,29 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return directCommand(context.Background(), cfg, cmdArgs, stdin, stdout) } if cmd == "admin" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } return brokerAdminCommandWithInput(cfg, cmdArgs, stdin, stdout) } + if cmd == "janitor" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return janitorCommand(cfg, cmdArgs, stdout) + } if cmd == "ssh" { return sshCommand(cfg, cmdArgs, stdout, stderr) } @@ -104,7 +127,23 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if cmd == "broker" { return brokerCommand(context.Background(), cfg, cmdArgs, stdin, stdout) } + if cmd == "repos" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return reposCommand(context.Background(), cfg, cmdArgs, stdout) + } if cmd == "pr" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } return prCommand(cmdArgs, stdin, stdout) } if cmd == "web" { @@ -135,8 +174,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { localCfg, err := readLocalConfig(".") if err == nil { cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) } } + if cmd == "whoami" { + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return whoamiCommand(context.Background(), cfg, cmdArgs, stdout) + } if !cfg.direct && cfg.gcloudConfigurationExplicit && isNativeRemoteCommand(cmd) { if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { return err @@ -415,6 +463,15 @@ func extractGlobalFlags(args []string, cfg *config) ([]string, error) { } cfg.gcloudConfiguration = value cfg.gcloudConfigurationExplicit = true + case "--identity": + if !hasValue { + i++ + if i >= len(args) { + return nil, errors.New("--identity requires a value") + } + value = args[i] + } + cfg.identity = value case "--version", "-v": cfg.versionRequested = true default: @@ -470,6 +527,9 @@ func mergeConfig(primary, fallback config) config { if !primary.gcloudConfigurationExplicit && fallback.gcloudConfiguration != "" { primary.gcloudConfiguration = fallback.gcloudConfiguration } + if primary.identity == "" { + primary.identity = fallback.identity + } return primary } @@ -529,6 +589,7 @@ func readLocalConfig(dir string) (config, error) { localProvider = strings.TrimSpace(string(providerOut)) } if brokerURL != "" && logicalRepo != "" { + identity := localIdentityPreference(dir) provider := firstNonEmpty(localProvider, "gcs") return config{ provider: provider, @@ -537,6 +598,7 @@ func readLocalConfig(dir string) (config, error) { origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo), brokerURL: brokerURL, logicalRepo: logicalRepo, + identity: identity, auth: localAuth.auth, gcloudConfiguration: localAuth.gcloudConfiguration, }, nil @@ -589,6 +651,18 @@ func readLocalConfig(dir string) (config, error) { }, nil } +func localIdentityPreference(dir string) string { + for _, key := range []string{"bucketgit.sshKeyFingerprint", "bucketgit.sshKey", "bucketgit.identity"} { + out, err := runGit(dir, "config", "--get", key) + if err == nil { + if value := strings.TrimSpace(string(out)); value != "" { + return value + } + } + } + return "" +} + func defaultBranchAuth(dir string) config { cfg := config{auth: defaultAuthMode} if out, err := runGit(dir, "config", "--get", "bucketgit.auth"); err == nil { @@ -801,12 +875,16 @@ collaborate pr Create, review, merge, and close pull requests administer + whoami Show broker identity, role, and capabilities for this repo + repos List repositories visible to local SSH keys admin Manage broker-backed users, keys, owners, and protection + janitor Run broker maintenance and repair tasks broker Delete or decommission deployed broker infrastructure web Browse a repository locally global options: --profile NAME + --identity KEY_OR_FINGERPRINT --version Legacy direct bucket operations are under "bgit direct". @@ -955,6 +1033,23 @@ examples: Broker-backed pull request metadata and merge/ref protection workflow. Pull requests are stored in the broker control plane, not in Git itself. +`, + "whoami": `usage: bgit whoami [--json] [--refresh] [--all] + +Show the SSH identity, repo role, and broker capabilities for the current +broker-backed repository. Results are cached under ~/.bgit/cache//. +Use --all to list repositories visible to the SSH keys currently loaded in +ssh-agent. +`, + "repos": `usage: bgit repos mine [--json] + +List repositories visible to the SSH keys currently loaded in ssh-agent using +the broker membership index. +`, + "janitor": `usage: bgit janitor members reindex + +Broker maintenance and repair commands. These commands rebuild derived broker +metadata from authoritative repo state and are not needed for normal use. `, "direct": `usage: bgit direct help diff --git a/ssh.go b/ssh.go index f238d48..9df39ff 100644 --- a/ssh.go +++ b/ssh.go @@ -25,6 +25,12 @@ import ( const defaultSSHHost = "git.bucketgit.com" +var brokerIdentityPreference string + +func setBrokerIdentityPreference(value string) { + brokerIdentityPreference = strings.TrimSpace(value) +} + type sshSetupOptions struct { broker string region string @@ -731,62 +737,115 @@ func brokerPostContext(ctx context.Context, brokerURL, path string, req any, res if err != nil { return err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) - if err != nil { - return err - } - httpReq.Header.Set("content-type", "application/json") - for key, value := range brokerSignatureHeaders(data) { - httpReq.Header.Set(key, value) - } - httpResp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return err - } - defer httpResp.Body.Close() - body, readErr := io.ReadAll(httpResp.Body) - if readErr != nil { - return readErr + headerSets := brokerSignatureHeaderSetsForBroker(brokerURL, data) + if len(headerSets) == 0 { + headerSets = []map[string]string{{}} } - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + var lastErr error + for i, headers := range headerSets { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + httpReq.Header.Set("content-type", "application/json") + for key, value := range headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + body, readErr := io.ReadAll(httpResp.Body) + _ = httpResp.Body.Close() + if readErr != nil { + return readErr + } + if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { + if fingerprint := headers["X-Bgit-Key-Fingerprint"]; fingerprint != "" { + _ = writeRepoAuthCache(brokerURL, data, fingerprint) + } + if resp != nil && len(body) > 0 { + if err := json.Unmarshal(body, resp); err != nil { + return err + } + } + return nil + } msg := strings.TrimSpace(string(body)) if msg == "" { msg = httpResp.Status } - return fmt.Errorf("broker %s: %s", path, msg) - } - if resp != nil && len(body) > 0 { - if err := json.Unmarshal(body, resp); err != nil { - return err + lastErr = fmt.Errorf("broker %s: %s", path, msg) + if httpResp.StatusCode != http.StatusForbidden || i == len(headerSets)-1 { + return lastErr } } - return nil + return lastErr } func brokerSignatureHeaders(payload []byte) map[string]string { - headers := map[string]string{} + sets := brokerSignatureHeaderSetsForBroker("", payload) + if len(sets) == 0 { + return map[string]string{} + } + return sets[0] +} + +func brokerSignatureHeaderSets(payload []byte) []map[string]string { + return brokerSignatureHeaderSetsForBroker("", payload) +} + +func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[string]string { sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) if sock == "" { - return headers + return nil } conn, err := net.Dial("unix", sock) if err != nil { - return headers + return nil } defer conn.Close() signers, err := agent.NewClient(conn).Signers() if err != nil || len(signers) == 0 { - return headers + return nil } message := brokerSignatureMessage(payload) - sig, err := signers[0].Sign(nil, message) - if err != nil { - return headers + preferred := preferredBrokerKeyFingerprints(brokerURL, payload) + type signedHeaders struct { + fingerprint string + headers map[string]string + } + var signed []signedHeaders + for _, signer := range signers { + sig, err := signer.Sign(nil, message) + if err != nil { + continue + } + fingerprint := ssh.FingerprintSHA256(signer.PublicKey()) + signed = append(signed, signedHeaders{fingerprint: fingerprint, headers: map[string]string{ + "X-Bgit-Key": strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))), + "X-Bgit-Key-Fingerprint": fingerprint, + "X-Bgit-Signature": base64.StdEncoding.EncodeToString(ssh.Marshal(sig)), + "X-Bgit-Signature-Message": base64.StdEncoding.EncodeToString(message), + }}) + } + sort.SliceStable(signed, func(i, j int) bool { + return preferredBrokerKeyRank(signed[i].fingerprint, preferred) < preferredBrokerKeyRank(signed[j].fingerprint, preferred) + }) + var sets []map[string]string + for _, item := range signed { + sets = append(sets, item.headers) + } + return sets +} + +func preferredBrokerKeyRank(fingerprint string, preferred []string) int { + for i, value := range preferred { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(fingerprint)) { + return i + } } - headers["X-Bgit-Key"] = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signers[0].PublicKey()))) - headers["X-Bgit-Signature"] = base64.StdEncoding.EncodeToString(ssh.Marshal(sig)) - headers["X-Bgit-Signature-Message"] = base64.StdEncoding.EncodeToString(message) - return headers + return len(preferred) + 1 } func brokerSignatureMessage(payload []byte) []byte { diff --git a/web.go b/web.go index 27cc380..ce0dcc4 100644 --- a/web.go +++ b/web.go @@ -93,6 +93,11 @@ type webChangedFile struct { binary bool } +type webDiffRenderOptions struct { + Review bool + PRID int +} + type webRefOption struct { name string fullName string @@ -364,6 +369,8 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleEvents(w, r) case route == "api/state": s.handleAPIState(ctx, w, r) + case route == "api/me": + s.handleAPIMe(ctx, w, r) case route == "api/actions/commit": s.handleAPIActionCommit(ctx, w, r) case route == "api/actions/stage": @@ -459,6 +466,9 @@ func (s *webServer) startLiveMonitors(ctx context.Context) { if s.events == nil { return } + if s.cfg.brokerURL != "" && s.cfg.logicalRepo != "" { + go s.refreshWhoamiForWeb(ctx) + } if repo, err := openLocalRepository("."); err == nil { go monitorWebPath(ctx, repo.gitDir, "git", s.events) } @@ -467,6 +477,47 @@ func (s *webServer) startLiveMonitors(ctx context.Context) { } } +func (s *webServer) handleAPIMe(ctx context.Context, w http.ResponseWriter, r *http.Request) { + status, err := s.webWhoami(ctx, r.URL.Query().Get("refresh") == "1") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, status) +} + +func (s *webServer) cachedWhoamiJSON() string { + if s.cfg.brokerURL == "" { + return "null" + } + status, err := readWhoamiCache(s.cfg.brokerURL) + if err != nil { + return "null" + } + data, err := json.Marshal(status) + if err != nil { + return "null" + } + return string(data) +} + +func (s *webServer) webWhoami(ctx context.Context, refresh bool) (brokerAuthStatus, error) { + if s.cfg.brokerURL == "" || s.cfg.logicalRepo == "" { + return brokerAuthStatus{}, errors.New("whoami requires a broker-backed repository") + } + return brokerWhoami(ctx, s.cfg, refresh) +} + +func (s *webServer) refreshWhoamiForWeb(ctx context.Context) { + status, err := s.webWhoami(ctx, true) + if err != nil { + return + } + if s.events != nil { + s.events.broadcastJSON("whoami", status) + } +} + func (s *webServer) serverForRequest(r *http.Request, api bool) *webServer { if s.apiRepo == nil || s.apiRepo == s.repo { return s @@ -713,12 +764,22 @@ func (s *webServer) handleAPIDiff(ctx context.Context, w http.ResponseWriter, r return } if commit := strings.TrimSpace(r.URL.Query().Get("commit")); commit != "" { - diff, err := localCommitDiff(commit) - if err != nil { + files, additions, deletions, err := s.commitChangedFiles(ctx, commit) + if err == nil { + s.renderJSON(w, map[string]any{"commit": commit, "diff": changedFilesUnifiedDiff(files), "html": diffFilesPanelHTML(files, additions, deletions)}) + return + } + diffHTML, htmlErr := localCommitVisualDiffHTML(commit) + diff, diffErr := localCommitDiff(commit) + if diffErr != nil && htmlErr != nil { s.renderJSONError(w, http.StatusBadRequest, err) return } - s.renderJSON(w, map[string]any{"commit": commit, "diff": diff}) + resp := map[string]any{"commit": commit, "diff": diff} + if htmlErr == nil { + resp["html"] = diffHTML + } + s.renderJSON(w, resp) return } if prID := strings.TrimSpace(r.URL.Query().Get("pr")); prID != "" { @@ -732,7 +793,11 @@ func (s *webServer) handleAPIDiff(ctx context.Context, w http.ResponseWriter, r s.renderJSONError(w, http.StatusBadRequest, err) return } - s.renderJSON(w, map[string]any{"pr": id, "diff": diff}) + resp := map[string]any{"pr": id, "diff": diff} + if pr, prErr := s.pullRequestByID(ctx, id); prErr == nil { + resp["html"] = s.pullRequestFilesHTML(ctx, pr) + } + s.renderJSON(w, resp) return } repoPath := cleanWebPath(r.URL.Query().Get("path")) @@ -749,11 +814,16 @@ func (s *webServer) handleAPIDiff(ctx context.Context, w http.ResponseWriter, r s.renderJSONError(w, http.StatusBadRequest, err) return } - s.renderJSON(w, map[string]any{ + diffHTML, htmlErr := localFileVisualDiffHTML(repoPath, mode) + resp := map[string]any{ "path": repoPath, "mode": mode, "diff": diff, - }) + } + if htmlErr == nil { + resp["html"] = diffHTML + } + s.renderJSON(w, resp) _ = ctx } @@ -997,10 +1067,13 @@ func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.Respo return } var req struct { - ID int `json:"id"` - Action string `json:"action"` - Comment string `json:"comment"` - DeleteBranch bool `json:"delete_branch"` + ID int `json:"id"` + Action string `json:"action"` + Comment string `json:"comment"` + DeleteBranch bool `json:"delete_branch"` + Comments []brokerPullRequestComment `json:"comments"` + TargetNoteID int `json:"target_note_id"` + TargetCommentID int `json:"target_comment_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.renderJSONError(w, http.StatusBadRequest, err) @@ -1014,10 +1087,13 @@ func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.Respo PR brokerPullRequest `json:"pr"` } brokerReq := brokerPullRequestRequest{ - Repo: repoForBroker(s.cfg), - ID: req.ID, - Comment: strings.TrimSpace(req.Comment), - DeleteBranch: req.DeleteBranch, + Repo: repoForBroker(s.cfg), + ID: req.ID, + Comment: strings.TrimSpace(req.Comment), + DeleteBranch: req.DeleteBranch, + Comments: req.Comments, + TargetNoteID: req.TargetNoteID, + TargetCommentID: req.TargetCommentID, } endpoint := "" switch strings.TrimSpace(req.Action) { @@ -1033,11 +1109,22 @@ func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.Respo case "reject": endpoint = "/prs/review" brokerReq.Review = "changes_requested" + case "review-comment": + endpoint = "/prs/review" + brokerReq.Review = "commented" + case "reply": + if brokerReq.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("comment is required")) + return + } + endpoint = "/prs/reply" case "merge": endpoint = "/prs/merge" brokerReq.Merge = true case "close": endpoint = "/prs/close" + case "reopen": + endpoint = "/prs/reopen" default: s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported pull request action %q", req.Action)) return @@ -1210,6 +1297,52 @@ func localFileDiff(repoPath, mode string) (string, error) { } } +func (s *webServer) commitChangedFiles(ctx context.Context, hash string) ([]webChangedFile, int, int, error) { + commitHash, err := s.repo.resolveRevision(ctx, hash) + if err != nil { + commitHash = hash + } + commit, err := s.repo.commit(ctx, commitHash) + if err != nil { + return nil, 0, 0, err + } + return s.changedFiles(ctx, commit) +} + +func localFileVisualDiffHTML(repoPath, mode string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + repoPath = canonicalWorktreePath(repo, repoPath) + var oldData, newData []byte + switch mode { + case "staged", "cached": + oldData = gitBlobData("HEAD:" + repoPath) + newData = gitBlobData(":" + repoPath) + case "worktree", "unstaged", "": + oldData = gitBlobData(":" + repoPath) + if oldData == nil { + oldData = gitBlobData("HEAD:" + repoPath) + } + if data, err := os.ReadFile(repoPath); err == nil { + newData = data + } + default: + return "", fmt.Errorf("unsupported diff mode %q", mode) + } + file := webChangedFileFromData(repoPath, "", "", oldData, newData) + return diffFileHTML(file), nil +} + +func gitBlobData(revisionPath string) []byte { + out, err := runGit(".", "show", revisionPath) + if err != nil { + return nil + } + return out +} + func localUntrackedFileDiff(repoPath string) (string, error) { data, err := os.ReadFile(repoPath) if err != nil { @@ -1260,6 +1393,54 @@ func localCommitDiff(hash string) (string, error) { return string(out), nil } +func localCommitVisualDiffHTML(hash string) (string, error) { + fullHash, err := gitOutputLine("rev-parse", hash) + if err != nil { + return "", err + } + parentLine, _ := gitOutputLine("show", "-s", "--format=%P", fullHash) + parent := "" + if fields := strings.Fields(parentLine); len(fields) > 0 { + parent = fields[0] + } + var namesOut []byte + if parent != "" { + namesOut, err = runGit(".", "diff", "--name-only", parent, fullHash) + } else { + namesOut, err = runGit(".", "show", "--format=", "--name-only", fullHash) + } + if err != nil { + return "", err + } + var files []webChangedFile + totalAdditions := 0 + totalDeletions := 0 + for _, name := range strings.Split(strings.TrimSpace(string(namesOut)), "\n") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + var oldData []byte + if parent != "" { + oldData = gitBlobData(parent + ":" + name) + } + newData := gitBlobData(fullHash + ":" + name) + file := webChangedFileFromData(name, "", "", oldData, newData) + totalAdditions += file.additions + totalDeletions += file.deletions + files = append(files, file) + } + return diffFilesPanelHTML(files, totalAdditions, totalDeletions), nil +} + +func gitOutputLine(args ...string) (string, error) { + out, err := runGit(".", args...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + func uniqueSortedStrings(values []string) []string { seen := map[string]struct{}{} for _, value := range values { @@ -1409,7 +1590,7 @@ func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *ht body.WriteString(s.repoToolbarHTML(ref, true)) body.WriteString(s.fileIndexHTML(ctx, commit.tree, ref)) body.WriteString(`
    `) - body.WriteString(`
    ` + html.EscapeString(commit.author) + `` + html.EscapeString(displayCommitSubject(commit)) + `` + html.EscapeString(shortHash(commit.hash)) + `` + html.EscapeString(relativeTime(commit.timestamp)) + `
    `) + body.WriteString(`
    ` + html.EscapeString(commit.author) + `` + html.EscapeString(displayCommitSubject(commit)) + `` + html.EscapeString(shortHash(commit.hash)) + `` + html.EscapeString(relativeTime(commit.timestamp)) + `
    `) if repoPath != "" && repoPath != "commits" && repoPath != "prs" { parent := pathpkg.Dir(repoPath) if parent == "." { @@ -1447,6 +1628,15 @@ func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r s.renderError(w, http.StatusNotFound, err) return } + selectedHash := strings.TrimSpace(r.URL.Query().Get("commit")) + if selectedHash == "" { + selectedHash = strings.TrimSpace(r.URL.Query().Get("selected")) + } + if selectedHash != "" { + if resolved, err := s.repo.resolveRevision(ctx, selectedHash); err == nil { + selectedHash = resolved + } + } commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, "") if err != nil { s.renderError(w, http.StatusInternalServerError, err) @@ -1456,7 +1646,7 @@ func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r body.WriteString(`
    `) body.WriteString(s.headerHTML(ref, "commits")) body.WriteString(`
    Commits
    `) - body.WriteString(commitListHTML(commits, ref, false)) + body.WriteString(s.commitListHTML(ctx, commits, ref, false, selectedHash)) body.WriteString(`
    `) s.renderPage(w, webPageTitle(s.title, "commits"), body.String()) } @@ -1513,8 +1703,10 @@ func (s *webServer) handlePullRequest(ctx context.Context, w http.ResponseWriter body.WriteString(s.pullRequestFilesHTML(ctx, pr)) case "commits": body.WriteString(s.pullRequestCommitsHTML(ctx, pr)) + case "review": + body.WriteString(s.pullRequestReviewHTML(ctx, pr)) default: - body.WriteString(pullRequestConversationHTML(pr)) + body.WriteString(s.pullRequestConversationHTML(ctx, pr)) } body.WriteString(``) s.renderPage(w, webPageTitle(s.title, fmt.Sprintf("PR #%d", pr.ID)), body.String()) @@ -1539,6 +1731,37 @@ func (s *webServer) pullRequestByID(ctx context.Context, id int) (brokerPullRequ } func (s *webServer) pullRequestFilesHTML(ctx context.Context, pr brokerPullRequest) string { + files, additions, deletions, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return `
    ` + html.EscapeString(err.Error()) + `
    ` + } + return diffFilesPanelHTML(files, additions, deletions) +} + +func (s *webServer) pullRequestReviewHTML(ctx context.Context, pr brokerPullRequest) string { + files, additions, deletions, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return `
    ` + html.EscapeString(err.Error()) + `
    ` + } + var b strings.Builder + commentJSON, _ := json.Marshal(prReviewComments(pr)) + b.WriteString(``) + b.WriteString(`
    Review changes
    Hover a new-side line or file header to add review comments. Submit them together at the bottom.
    `) + b.WriteString(`
    `) + b.WriteString(diffFilesPanelHTMLWithOptions(files, additions, deletions, webDiffRenderOptions{Review: true, PRID: pr.ID})) + b.WriteString(`
    Cancel review
    `) + return b.String() +} + +func prReviewComments(pr brokerPullRequest) []brokerPullRequestComment { + var comments []brokerPullRequestComment + for _, review := range pr.Reviews { + comments = append(comments, review.Comments...) + } + return comments +} + +func (s *webServer) pullRequestChangedFiles(ctx context.Context, pr brokerPullRequest) ([]webChangedFile, int, int, error) { repo := s targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) sourceRef := firstNonEmpty(pr.Source, pr.Head) @@ -1552,21 +1775,17 @@ func (s *webServer) pullRequestFilesHTML(ctx context.Context, pr brokerPullReque sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) } if targetErr != nil || sourceErr != nil { - return `
    Pull request refs are not available locally yet. Fetch the source and target branches, then refresh this page.
    ` + return nil, 0, 0, errors.New("pull request refs are not available locally yet. Fetch the source and target branches, then refresh this page") } targetCommit, err := repo.repo.commit(ctx, targetHash) if err != nil { - return `
    ` + html.EscapeString(err.Error()) + `
    ` + return nil, 0, 0, err } sourceCommit, err := repo.repo.commit(ctx, sourceHash) if err != nil { - return `
    ` + html.EscapeString(err.Error()) + `
    ` - } - files, additions, deletions, err := repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) - if err != nil { - return `
    ` + html.EscapeString(err.Error()) + `
    ` + return nil, 0, 0, err } - return diffFilesPanelHTML(files, additions, deletions) + return repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) } func (s *webServer) pullRequestUnifiedDiff(ctx context.Context, id int) (string, error) { @@ -1660,7 +1879,7 @@ func (s *webServer) pullRequestCommitsHTML(ctx context.Context, pr brokerPullReq if err != nil { return `
    ` + html.EscapeString(err.Error()) + `
    ` } - return `
    Commits
    ` + commitListHTML(commits, firstNonEmpty(pr.Source, pr.Head), false) + `
    ` + return `
    Commits
    ` + s.commitListHTML(ctx, commits, firstNonEmpty(pr.Source, pr.Head), false, "") + `
    ` } func (s *webServer) resolvePullRequestRevision(ctx context.Context, ref, fallbackHash string) (string, error) { @@ -1760,45 +1979,8 @@ func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r * s.renderError(w, http.StatusNotFound, fs.ErrNotExist) return } - commitHash, err := s.repo.resolveRevision(ctx, hash) - if err != nil { - commitHash = hash - } - commit, err := s.repo.commit(ctx, commitHash) - if err != nil { - s.renderError(w, http.StatusNotFound, err) - return - } ref := strings.TrimSpace(r.URL.Query().Get("ref")) - files, additions, deletions, err := s.changedFiles(ctx, commit) - if err != nil { - s.renderError(w, http.StatusInternalServerError, err) - return - } - var body strings.Builder - body.WriteString(`
    `) - body.WriteString(s.headerHTML(firstNonEmpty(ref, commit.hash), "commit/"+shortHash(commit.hash))) - body.WriteString(`
    `) - body.WriteString(`

    ` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

    `) - if commit.body != "" { - body.WriteString(`
    ` + html.EscapeString(commit.body) + `
    `) - } - body.WriteString(`
    `) - body.WriteString(diffFilesPanelHTML(files, additions, deletions)) - body.WriteString(`
    `) - s.renderPage(w, webPageTitle(s.title, shortHash(commit.hash)), body.String()) + http.Redirect(w, r, webCommitURL(hash, ref), http.StatusFound) } func (s *webServer) handleBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string, raw bool) { @@ -1971,7 +2153,7 @@ func (s *webServer) headerHTML(ref, repoPath string) string { if codeActive != "" { codeActions = ` data-code-actions="true"` } - b.WriteString(`
    `) + b.WriteString(`
    `) if location := s.repoLocationBadge(); location != "" { b.WriteString(`
    ` + html.EscapeString(location) + `
    `) } @@ -2307,6 +2489,7 @@ func (s *webServer) renderPage(w http.ResponseWriter, title, body string) { page = strings.ReplaceAll(page, "{{CSS}}", webAssetString(webCSSPath)) page = strings.ReplaceAll(page, "{{SOURCE}}", source) page = strings.ReplaceAll(page, "{{BODY}}", body) + page = strings.ReplaceAll(page, "{{WHOAMI}}", s.cachedWhoamiJSON()) page = strings.ReplaceAll(page, "{{JS}}", webAssetString(webJSPath)) fmt.Fprint(w, page) } @@ -2529,6 +2712,8 @@ func webAPIPullRequests(prs []brokerPullRequest) []map[string]any { "approvals": pr.Approvals, "checks": pr.Checks, "head": pr.Head, + "comments": pr.Comments, + "reviews": pr.Reviews, }) } return out @@ -2543,7 +2728,8 @@ func pullRequestListHTML(prs []brokerPullRequest) string { for _, pr := range prs { status := firstNonEmpty(pr.Status, "open") title := firstNonEmpty(pr.Title, "Untitled pull request") - b.WriteString(`
  • ` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target)) + `
    ` + html.EscapeString(status) + ``) + prURL := `/prs/` + strconv.Itoa(pr.ID) + b.WriteString(`
  • ` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target)) + `
    ` + html.EscapeString(status) + ``) if pr.Approvals > 0 { b.WriteString(`` + strconv.Itoa(pr.Approvals) + ` approval`) if pr.Approvals != 1 { @@ -2563,12 +2749,16 @@ func prHeaderHTML(pr brokerPullRequest, active string) string { filesActive := "" conversationActive := ` class="active"` commitsActive := "" + reviewActive := "" if active == "files" || active == "files-changed" || active == "diff" { conversationActive = "" filesActive = ` class="active"` } else if active == "commits" { conversationActive = "" commitsActive = ` class="active"` + } else if active == "review" { + conversationActive = "" + reviewActive = ` class="active"` } id := strconv.Itoa(pr.ID) var b strings.Builder @@ -2579,12 +2769,13 @@ func prHeaderHTML(pr brokerPullRequest, active string) string { b.WriteString(` by ` + html.EscapeString(pr.Author)) } b.WriteString(`
    `) - b.WriteString(`
    `) + b.WriteString(`
    `) b.WriteString(``) return b.String() } -func pullRequestConversationHTML(pr brokerPullRequest) string { +func (s *webServer) pullRequestConversationHTML(ctx context.Context, pr brokerPullRequest) string { + contexts := s.prInlineCommentContexts(ctx, pr) var b strings.Builder b.WriteString(`
    Conversation
    `) if strings.TrimSpace(pr.Body) != "" { @@ -2594,7 +2785,7 @@ func pullRequestConversationHTML(pr brokerPullRequest) string { } b.WriteString(`
    `) for _, comment := range pr.Comments { - b.WriteString(prNoteHTML(comment, "commented")) + b.WriteString(prNoteHTML(comment, "commented", contexts)) } for _, review := range pr.Reviews { label := "reviewed" @@ -2603,41 +2794,276 @@ func pullRequestConversationHTML(pr brokerPullRequest) string { } else if review.State == "changes_requested" { label = "requested changes" } - b.WriteString(prNoteHTML(review, label)) + b.WriteString(prNoteHTML(review, label, contexts)) } if len(pr.Comments) == 0 && len(pr.Reviews) == 0 { b.WriteString(`
    No comments or reviews yet.
    `) } b.WriteString(`
    `) if firstNonEmpty(pr.Status, "open") == "open" { - b.WriteString(`
    `) + b.WriteString(``) + b.WriteString(`
    `) } else { - b.WriteString(`
    This pull request is ` + html.EscapeString(firstNonEmpty(pr.Status, "closed")) + `.
    `) + b.WriteString(`
    This pull request is ` + html.EscapeString(firstNonEmpty(pr.Status, "closed")) + `.
    `) } b.WriteString(`
    `) return b.String() } -func prNoteHTML(note brokerPullRequestNote, action string) string { +func (s *webServer) prInlineCommentContexts(ctx context.Context, pr brokerPullRequest) map[string][]webVisualDiffRow { + contexts := map[string][]webVisualDiffRow{} + if len(pr.Comments) == 0 && len(pr.Reviews) == 0 { + return contexts + } + files, _, _, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return contexts + } + filesByPath := map[string]webChangedFile{} + for _, file := range files { + filesByPath[file.path] = file + } + collect := func(note brokerPullRequestNote) { + for _, comment := range note.Comments { + if comment.Kind != "line" || comment.File == "" || comment.Line <= 0 { + continue + } + file, ok := filesByPath[comment.File] + if !ok { + continue + } + rows := file.visual + if len(rows) == 0 { + rows = webVisualDiffRows(file.diff) + } + context := prCommentAfterContextRows(rows, comment) + if len(context) > 0 { + contexts[prInlineCommentKey(comment)] = context + } + } + } + for _, note := range pr.Comments { + collect(note) + } + for _, note := range pr.Reviews { + collect(note) + } + return contexts +} + +func prCommentAfterContextRows(rows []webVisualDiffRow, comment brokerPullRequestComment) []webVisualDiffRow { + target := -1 + targetLine := strconv.Itoa(comment.Line) + for i, row := range rows { + if row.newLine == targetLine { + target = i + break + } + if target < 0 && comment.HunkIndex >= 0 && row.hunkIndex == comment.HunkIndex && row.offset == comment.Offset && row.newLine != "" { + target = i + } + } + if target < 0 { + return nil + } + hunkIndex := rows[target].hunkIndex + var context []webVisualDiffRow + for _, row := range rows { + if row.hidden || row.newLine == "" { + continue + } + if hunkIndex >= 0 && row.hunkIndex != hunkIndex { + continue + } + if row.kind == "hunk" || row.kind == "hunk-top" || row.kind == "hunk-bottom" || row.kind == "note" { + continue + } + context = append(context, row) + } + if len(context) == 0 { + context = append(context, rows[target]) + } + return context +} + +func prNoteHTML(note brokerPullRequestNote, action string, contexts map[string][]webVisualDiffRow) string { user := firstNonEmpty(note.User, "unknown") when := note.At if parsed, err := time.Parse(time.RFC3339, note.At); err == nil { when = relativeTime(parsed.Unix()) } var b strings.Builder - b.WriteString(`
    ` + html.EscapeString(user) + ` ` + html.EscapeString(action)) + b.WriteString(`
    ` + html.EscapeString(user) + ` ` + html.EscapeString(action)) if when != "" { b.WriteString(` ` + html.EscapeString(when) + ``) } - b.WriteString(`
    `) + b.WriteString(`
    `) if strings.TrimSpace(note.Body) != "" { b.WriteString(`
    ` + html.EscapeString(note.Body) + `
    `) } + if len(note.Replies) > 0 { + b.WriteString(prReplyThreadHTML(note.Replies, 1)) + } + if len(note.Comments) > 0 { + b.WriteString(`
    `) + for _, comment := range note.Comments { + b.WriteString(prInlineCommentHTML(note.ID, comment, contexts[prInlineCommentKey(comment)])) + } + b.WriteString(`
    `) + } + b.WriteString(`
    `) + return b.String() +} + +func prInlineCommentKey(comment brokerPullRequestComment) string { + return strings.Join([]string{ + comment.File, + comment.Kind, + strconv.Itoa(comment.Line), + strconv.Itoa(comment.HunkIndex), + strconv.Itoa(comment.Offset), + comment.Head, + comment.Body, + }, "\x00") +} + +func prInlineCommentHTML(noteID int, comment brokerPullRequestComment, context []webVisualDiffRow) string { + file := firstNonEmpty(comment.File, "Changed file") + line := "" + if comment.Kind == "line" && comment.Line > 0 { + line = strconv.Itoa(comment.Line) + } + var b strings.Builder + b.WriteString(`
    `) + b.WriteString(`
    ` + html.EscapeString(file) + ``) + if line != "" { + b.WriteString(`line ` + html.EscapeString(line) + ``) + } + if comment.Outdated { + b.WriteString(`outdated`) + } + b.WriteString(``) + b.WriteString(`
    `) + if comment.Kind == "line" { + if len(context) > 0 { + b.WriteString(`
    `) + targetRendered := false + for _, row := range context { + target := row.newLine == line + if target { + targetRendered = true + } + b.WriteString(prInlineAfterRowHTML(row, target)) + if target { + b.WriteString(`
    ` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
    ` + prReplyThreadHTML(comment.Replies, 1)) + } + } + } + if !targetRendered { + b.WriteString(`
    ` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
    ` + prReplyThreadHTML(comment.Replies, 1)) + } + } + b.WriteString(`
    `) + } else { + b.WriteString(`
    `) + b.WriteString(`
    ` + html.EscapeString(line) + `
    `) + lineText := comment.LineText + if strings.TrimSpace(lineText) == "" { + lineText = "(line context unavailable)" + } + b.WriteString(`
    ` + html.EscapeString(lineText) + `
    `) + b.WriteString(`
    ` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
    ` + prReplyThreadHTML(comment.Replies, 1)) + } + b.WriteString(`
    `) + } + } else { + b.WriteString(`
    File comment
    ` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID)) + `
    `) + if len(comment.Replies) > 0 { + b.WriteString(prReplyThreadHTML(comment.Replies, 1)) + } + } b.WriteString(`
    `) return b.String() } -func commitListHTML(commits []commitObject, ref string, compact bool) string { +func prReplyThreadHTML(replies []brokerPullRequestComment, depth int) string { + if len(replies) == 0 { + return "" + } + if depth > 5 { + depth = 5 + } + var b strings.Builder + b.WriteString(`
    `) + for _, reply := range replies { + user := firstNonEmpty(reply.User, "unknown") + when := reply.At + if parsed, err := time.Parse(time.RFC3339, reply.At); err == nil { + when = relativeTime(parsed.Unix()) + } + b.WriteString(`
    ` + html.EscapeString(user) + ` commented`) + if when != "" { + b.WriteString(` ` + html.EscapeString(when) + ``) + } + b.WriteString(`
    `) + b.WriteString(`
    ` + html.EscapeString(reply.Body) + `
    `) + b.WriteString(`
    `) + if len(reply.Replies) > 0 { + b.WriteString(prReplyThreadHTML(reply.Replies, depth+1)) + } + } + b.WriteString(`
    `) + return b.String() +} + +func prReplyAttrs(noteID, commentID int) string { + attrs := ` data-pr-reply` + if noteID > 0 { + attrs += ` data-target-note-id="` + strconv.Itoa(noteID) + `"` + } + if commentID > 0 { + attrs += ` data-target-comment-id="` + strconv.Itoa(commentID) + `"` + } + return attrs +} + +func prInlineCommentBodyHTML(comment brokerPullRequestComment, replyAttrs string) string { + reply := "" + if replyAttrs != "" { + reply = `` + } + user := firstNonEmpty(comment.User, "unknown") + when := comment.At + if parsed, err := time.Parse(time.RFC3339, comment.At); err == nil { + when = relativeTime(parsed.Unix()) + } + meta := `
    ` + html.EscapeString(user) + ` commented` + if when != "" { + meta += ` ` + html.EscapeString(when) + `` + } + meta += `
    ` + return `
    ` + meta + `
    ` + html.EscapeString(comment.Body) + `
    ` + reply + `
    ` +} + +func prInlineAfterRowHTML(row webVisualDiffRow, target bool) string { + right := webDiffCellHTML(row.right, false, row.kind == "add") + if row.kind == "change" { + _, right = webInlineChangedHTML(row.left, row.right) + } + targetClass := "" + if target { + targetClass = " pr-inline-target-line" + } + return `
    ` + html.EscapeString(row.newLine) + `
    ` + right + `
    ` +} + +func (s *webServer) commitListHTML(ctx context.Context, commits []commitObject, ref string, compact bool, selectedHash string) string { if len(commits) == 0 { return `
    No commits.
    ` } @@ -2648,11 +3074,21 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { if commit.timestamp > 0 { when = time.Unix(commit.timestamp, 0).Format("2006-01-02 15:04") } - b.WriteString(`
  • ` + html.EscapeString(displayCommitSubject(commit)) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) + selected := selectedHash != "" && (commit.hash == selectedHash || strings.HasPrefix(commit.hash, selectedHash) || strings.HasPrefix(selectedHash, commit.hash)) + selectedClass := "" + if selected { + selectedClass = ` class="is-selected-commit"` + } + commitURL := webCommitURL(commit.hash, ref) + b.WriteString(`
    ` + html.EscapeString(displayCommitSubject(commit)) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) if !compact && commit.committer != "" && (commit.committer != commit.author || commit.committerEmail != commit.email) { b.WriteString(` Ā· committed by ` + html.EscapeString(commit.committer)) } - b.WriteString(`
    ` + html.EscapeString(shortHash(commit.hash)) + `
  • `) + b.WriteString(``) + if selected { + b.WriteString(s.commitInlineDetailHTML(ctx, commit, ref)) + } + b.WriteString(``) } if compact { b.WriteString(``) @@ -2662,6 +3098,36 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { return b.String() } +func (s *webServer) commitInlineDetailHTML(ctx context.Context, commit commitObject, ref string) string { + files, additions, deletions, err := s.changedFiles(ctx, commit) + if err != nil { + return `
    ` + html.EscapeString(err.Error()) + `
    ` + } + var b strings.Builder + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`

    ` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

    `) + if commit.body != "" { + b.WriteString(`
    ` + html.EscapeString(commit.body) + `
    `) + } + b.WriteString(`
    `) + b.WriteString(diffFilesPanelHTML(files, additions, deletions)) + b.WriteString(`
    `) + return b.String() +} + func displayCommitSubject(commit commitObject) string { const maxSubjectRunes = 80 subject := firstNonBlankLine(commit.subject) @@ -2735,9 +3201,9 @@ func urlQueryEscape(value string) string { } func webCommitURL(hash, ref string) string { - value := "/commit/" + hash + value := "/commits?commit=" + urlQueryEscape(hash) if strings.TrimSpace(ref) != "" { - value += "?ref=" + urlQueryEscape(ref) + value += "&ref=" + urlQueryEscape(ref) } return value } @@ -2872,25 +3338,29 @@ func (s *webServer) collectTreeFiles(ctx context.Context, treeHash, prefix strin } func (s *webServer) changedFile(ctx context.Context, path, oldHash, newHash string) (webChangedFile, error) { - file := webChangedFile{path: path, oldHash: oldHash, newHash: newHash} var oldData, newData []byte if oldHash != "" { obj, err := s.repo.object(ctx, oldHash) if err != nil { - return file, err + return webChangedFile{path: path, oldHash: oldHash, newHash: newHash}, err } oldData = obj.data } if newHash != "" { obj, err := s.repo.object(ctx, newHash) if err != nil { - return file, err + return webChangedFile{path: path, oldHash: oldHash, newHash: newHash}, err } newData = obj.data } + return webChangedFileFromData(path, oldHash, newHash, oldData, newData), nil +} + +func webChangedFileFromData(path, oldHash, newHash string, oldData, newData []byte) webChangedFile { + file := webChangedFile{path: path, oldHash: oldHash, newHash: newHash} if !isTextBlob(oldData) || !isTextBlob(newData) { file.binary = true - return file, nil + return file } file.visual = webVisualRowsFromText(string(oldData), string(newData), 3) for _, line := range simpleLineDiff(string(oldData), string(newData)) { @@ -2909,20 +3379,32 @@ func (s *webServer) changedFile(ctx context.Context, path, oldHash, newHash stri } file.diff = append(file.diff, diffLine) } - return file, nil + return file } func diffFileHTML(file webChangedFile) string { + return diffFileHTMLWithOptions(file, webDiffRenderOptions{}) +} + +func diffFileHTMLWithOptions(file webChangedFile, opts webDiffRenderOptions) string { var b strings.Builder - b.WriteString(`
    ` + html.EscapeString(file.path) + `
    `) - if file.oldHash == "" { + reviewAttrs := "" + reviewButton := "" + if opts.Review { + reviewAttrs = ` data-review-file="` + html.EscapeString(file.path) + `"` + reviewButton = `` + } + b.WriteString(`
    ` + html.EscapeString(file.path) + `
    `) + if file.oldHash == "" && file.newHash == "" { + b.WriteString(`local changes`) + } else if file.oldHash == "" { b.WriteString(`added`) } else if file.newHash == "" { b.WriteString(`deleted`) } else { b.WriteString(shortHash(file.oldHash) + ` -> ` + shortHash(file.newHash)) } - b.WriteString(`
    +` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) + b.WriteString(`
    ` + reviewButton + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) if file.binary { b.WriteString(`
    Binary file changed.
    `) } else if len(file.visual) > 0 { @@ -2935,13 +3417,18 @@ func diffFileHTML(file webChangedFile) string { } type webVisualDiffRow struct { - kind string - left string - right string - oldLine string - newLine string - control string - hidden bool + kind string + left string + right string + oldLine string + newLine string + control string + hunk string + hunkIndex int + oldStart int + newStart int + offset int + hidden bool } type webPendingDelete struct { @@ -2998,10 +3485,18 @@ func webVisualRowsFromText(left, right string, context int) []webVisualDiffRow { if hunkIndex > 0 && hunks[hunkIndex-1].end < hunk.start { control = "up" } - rows = append(rows, webVisualDiffRow{kind: "hunk-top", left: "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount), control: control}) + hunkLabel := "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount) + rows = append(rows, webVisualDiffRow{kind: "hunk-top", left: hunkLabel, control: control, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart}) } _, isVisible := visible[i] hidden := !isVisible + hunkIndex := webHunkIndexForOp(hunks, i) + hunkLabel := "" + oldStart, newStart := 0, 0 + if hunkIndex >= 0 { + oldStart, _, newStart, _ = simpleDiffHunkRange(ops[hunks[hunkIndex].start:hunks[hunkIndex].end]) + hunkLabel = "Lines " + webLineRangeLabel(oldStart, 1) + " -> " + webLineRangeLabel(newStart, 1) + } switch op.kind { case '-': pending = append(pending, webPendingDelete{text: op.text, line: op.oldLine}) @@ -3009,13 +3504,13 @@ func webVisualRowsFromText(left, right string, context int) []webVisualDiffRow { if len(pending) > 0 { deleted := pending[0] pending = pending[1:] - rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: op.text, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(op.newLine), hidden: hidden}) + rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: op.text, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) } else { - rows = append(rows, webVisualDiffRow{kind: "add", right: op.text, newLine: strconv.Itoa(op.newLine), hidden: hidden}) + rows = append(rows, webVisualDiffRow{kind: "add", right: op.text, newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) } default: flushDeletes(hidden) - rows = append(rows, webVisualDiffRow{kind: "same", left: op.text, right: op.text, oldLine: strconv.Itoa(op.oldLine), newLine: strconv.Itoa(op.newLine), hidden: hidden}) + rows = append(rows, webVisualDiffRow{kind: "same", left: op.text, right: op.text, oldLine: strconv.Itoa(op.oldLine), newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) } if hunkIndex, ok := hunkEnds[i+1]; ok { hunk := hunks[hunkIndex] @@ -3033,6 +3528,15 @@ func webVisualRowsFromText(left, right string, context int) []webVisualDiffRow { return rows } +func webHunkIndexForOp(hunks []simpleDiffHunk, opIndex int) int { + for i, hunk := range hunks { + if opIndex >= hunk.start && opIndex < hunk.end { + return i + } + } + return -1 +} + func webVisualDiffRows(lines []webDiffLine) []webVisualDiffRow { oldLine, newLine := 0, 0 var rows []webVisualDiffRow @@ -3098,7 +3602,8 @@ func webVisualDiffRowHTML(row webVisualDiffRow) string { if row.hidden { hidden = ` data-hidden-context="true" hidden` } - return `` + attrs := ` data-hunk-index="` + strconv.Itoa(row.hunkIndex) + `" data-hunk="` + html.EscapeString(row.hunk) + `" data-old-start="` + strconv.Itoa(row.oldStart) + `" data-new-start="` + strconv.Itoa(row.newStart) + `" data-offset="` + strconv.Itoa(row.offset) + `"` + return `` } func webDiffContextControlHTML(control string) string { @@ -3211,6 +3716,10 @@ func webLineRangeLabel(start, count int) string { } func diffFilesPanelHTML(files []webChangedFile, additions, deletions int) string { + return diffFilesPanelHTMLWithOptions(files, additions, deletions, webDiffRenderOptions{}) +} + +func diffFilesPanelHTMLWithOptions(files []webChangedFile, additions, deletions int, opts webDiffRenderOptions) string { var b strings.Builder b.WriteString(`
    ` + strconv.Itoa(len(files)) + ` changed file` + pluralSuffix(len(files)) + `+` + strconv.Itoa(additions) + `-` + strconv.Itoa(deletions) + `
    `) if len(files) == 0 { @@ -3223,7 +3732,7 @@ func diffFilesPanelHTML(files []webChangedFile, additions, deletions int) string } b.WriteString(`
    `) for _, file := range files { - b.WriteString(diffFileHTML(file)) + b.WriteString(diffFileHTMLWithOptions(file, opts)) } return b.String() } @@ -3358,6 +3867,22 @@ func (h *webEventHub) broadcast(name string) { } } +func (h *webEventHub) broadcastJSON(name string, value any) { + data, err := json.Marshal(value) + if err != nil { + return + } + payload := fmt.Sprintf("event: %s\ndata: %s\n\n", name, data) + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- payload: + default: + } + } +} + func monitorWebPath(ctx context.Context, root, eventName string, hub *webEventHub) { if root == "" || hub == nil { return From fa7019ad75d54888035eb260a87bda9df2254f07 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 08:29:21 +0200 Subject: [PATCH 03/17] Owner transfership and invite mechanics --- broker/aws/template.yaml | 393 ++++++++++++++++++++++-- broker/gcp/index.js | 412 +++++++++++++++++++++++-- broker_commands.go | 442 +++++++++++++++++++++++++-- main.go | 40 ++- main_test.go | 3 + setup.go | 2 +- ssh.go | 33 ++ web.go | 641 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1900 insertions(+), 66 deletions(-) diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index 411cad4..26b211c 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -34,6 +34,7 @@ Resources: - s3:ListBucket - s3:CreateBucket - s3:HeadBucket + - s3:DeleteBucket Resource: arn:aws:s3:::* BrokerRole: Type: AWS::IAM::Role @@ -57,6 +58,7 @@ Resources: Action: - dynamodb:GetItem - dynamodb:PutItem + - dynamodb:DeleteItem - dynamodb:Query - dynamodb:Scan Resource: @@ -76,6 +78,7 @@ Resources: - s3:ListBucket - s3:CreateBucket - s3:HeadBucket + - s3:DeleteBucket Resource: - arn:aws:s3:::* - arn:aws:s3:::*/* @@ -137,8 +140,8 @@ Resources: Code: ZipFile: | const crypto = require("crypto"); - const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand} = require("@aws-sdk/client-dynamodb"); - const {S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadBucketCommand, CreateBucketCommand} = require("@aws-sdk/client-s3"); + const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand, DeleteItemCommand} = require("@aws-sdk/client-dynamodb"); + const {S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadBucketCommand, CreateBucketCommand, DeleteBucketCommand} = require("@aws-sdk/client-s3"); const {STSClient, AssumeRoleCommand} = require("@aws-sdk/client-sts"); const db = new DynamoDBClient({}); const s3 = new S3Client({}); @@ -239,7 +242,10 @@ Resources: ScanIndexForward: false, ExclusiveStartKey: startKey })); - for (const item of out.Items || []) prs.push(JSON.parse(item.data.S || "{}")); + for (const item of out.Items || []) { + const pr = JSON.parse(item.data.S || "{}"); + if (Number(pr.id || 0) > 0 && pr.kind !== "issue") prs.push(pr); + } startKey = out.LastEvaluatedKey; } while (startKey); return prs; @@ -307,6 +313,32 @@ Resources: if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; return key; } + function submittedSignedKey(event) { + const publicKey = normalizeKey(header(event, "x-bgit-key")); + const message = String(header(event, "x-bgit-signature-message")); + const signature = String(header(event, "x-bgit-signature")); + if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; + const parsed = readSSHString(Buffer.from(signature, "base64"), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; + const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; + if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; + return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; + } + function ownershipTransferCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString("base64url"); + return "bgitot_" + payload; + } + function ownershipTransferTokenHash(token) { + return crypto.createHash("sha256").update(String(token || "")).digest("hex"); + } + function ownershipTransferExpired(transfer) { + return !transfer || !transfer.expires_at || Date.parse(transfer.expires_at) <= Date.now(); + } + function memberInviteCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString("base64url"); + return "bgitinv_" + payload; + } function verifySignature(event, entry) { const adminKeys = (entry.data.keys || []).filter((k) => (k.role === "admin" || k.role === "owner") && !k.suspended); if (adminKeys.length === 0) return true; @@ -325,6 +357,15 @@ Resources: const data = parts.length >= 2 ? Buffer.from(parts[1], "base64") : Buffer.from(normalizeKey(publicKey)); return "SHA256:" + crypto.createHash("sha256").update(data).digest("base64").replace(/=+$/g, ""); } + function keyMatches(item, key) { + const value = String(key || "").trim(); + if (!value) return false; + const normalized = normalizeKey(value); + return normalizeKey(item.public_key) === normalized || + item.public_key === value || + item.public_key.includes(value) || + keyFingerprint(item.public_key) === value; + } function roleCapabilities(role) { return { read: roleAllows(role, "read"), @@ -340,6 +381,15 @@ Resources: broker_upgrade: role === "owner" || role === "admin", }; } + function anonymousKey() { + return {user: "anonymous", role: "read", public_key: "", source: "public", anonymous: true}; + } + function repoIsPublic(entry) { + return (entry.data.visibility || "private") === "public"; + } + function repoIsReadOnly(entry) { + return !!entry.data.read_only; + } function validRole(role) { return ["owner", "admin", "maintainer", "developer", "triage", "read"].includes(role); } @@ -350,10 +400,17 @@ Resources: if (!verifySignature(event, entry)) throw Object.assign(new Error("admin SSH signature required"), {statusCode: 403}); } function requireOperation(event, entry, operation) { + if (operation !== "read" && repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); const key = signedKey(event, entry); + if (!key && operation === "read" && repoIsPublic(entry)) return anonymousKey(); if (!key || !roleAllows(key.role, operation)) throw Object.assign(new Error(operation + " SSH signature required"), {statusCode: 403}); return key; } + function requireIssueCreate(event, entry) { + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + if (repoIsPublic(entry)) return signedKey(event, entry) || anonymousKey(); + return requireOperation(event, entry, "read"); + } function cleanObjectPath(value) { const path = String(value || "").replace(/^\/+/, ""); if (path.includes("\0") || path.includes("..")) throw new Error("invalid object path"); @@ -396,6 +453,18 @@ Resources: async function deleteObject(repo, objectPath) { await s3.send(new DeleteObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); } + async function deletePhysicalRepo(repo) { + if (!repo.bucket) return; + let token = undefined; + do { + const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, ContinuationToken: token})); + for (const item of out.Contents || []) { + await s3.send(new DeleteObjectCommand({Bucket: repo.bucket, Key: item.Key})); + } + token = out.NextContinuationToken; + } while (token); + await s3.send(new DeleteBucketCommand({Bucket: repo.bucket})); + } async function listObjects(repo, prefix) { const repoPrefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); const queryPrefix = objectName(repo, prefix); @@ -547,6 +616,63 @@ Resources: } return Array.from(latest.values()).filter((state) => state === "approved").length; } + function nextIssueID(data) { + data.next_issue_id = Number(data.next_issue_id || 1); + return data.next_issue_id++; + } + function issueKey(repoID, id) { + return {repo_id: {S: repoID}, pr_id: {N: String(-Number(id))}}; + } + async function saveIssue(entry, issue) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...issueKey(entry.id, issue.id), data: {S: JSON.stringify(issue)}}})); + } + async function loadIssue(entry, id) { + const out = await db.send(new GetItemCommand({TableName: prTable, Key: issueKey(entry.id, id)})); + return out.Item ? JSON.parse(out.Item.data.S || "{}") : null; + } + async function listIssues(entry) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id AND pr_id < :zero", + ExpressionAttributeValues: {":repo_id": {S: entry.id}, ":zero": {N: "0"}}, + ScanIndexForward: false, + })); + return (out.Items || []).map((item) => JSON.parse(item.data.S || "{}")); + } + async function deleteRepoMetadata(entry) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id", + ExpressionAttributeValues: {":repo_id": {S: entry.id}}, + })); + for (const item of out.Items || []) { + await db.send(new DeleteItemCommand({TableName: prTable, Key: {repo_id: item.repo_id, pr_id: item.pr_id}})); + } + const repoIDValue = repoID(entry.data.repo || {}); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: keyFingerprint(key.public_key)}, repo_id: {S: repoIDValue}}})); + } + await db.send(new DeleteItemCommand({TableName: table, Key: {id: {S: entry.id}}})); + } + async function moveRepoRecords(oldID, newID) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id", + ExpressionAttributeValues: {":repo_id": {S: oldID}}, + })); + for (const item of out.Items || []) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...item, repo_id: {S: newID}}})); + await db.send(new DeleteItemCommand({TableName: prTable, Key: {repo_id: item.repo_id, pr_id: item.pr_id}})); + } + } + async function deleteMembershipIndex(entry) { + const repoIDValue = repoID(entry.data.repo || {}); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: keyFingerprint(key.public_key)}, repo_id: {S: repoIDValue}}})); + } + } async function updateRefCAS(repo, ref, oldHash, newHash, key, opts = {}) { const id = docID(repo); const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); @@ -611,6 +737,70 @@ Resources: await saveRepo(entry); return response(200, {ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix}); } + if (path === "/repo/info" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, { + repo: entry.data.repo || body.repo, + description: entry.data.description || "", + default_branch: entry.data.default_branch || "main", + visibility: entry.data.visibility || "private", + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + }); + } + if (path === "/repo/update" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + if (Object.prototype.hasOwnProperty.call(body, "description")) entry.data.description = String(body.description || "").trim(); + if (Object.prototype.hasOwnProperty.call(body, "default_branch")) entry.data.default_branch = String(body.default_branch || "").trim() || "main"; + if (Object.prototype.hasOwnProperty.call(body, "visibility")) entry.data.visibility = body.visibility === "public" ? "public" : "private"; + if (Object.prototype.hasOwnProperty.call(body, "read_only")) entry.data.read_only = !!body.read_only; + if (Object.prototype.hasOwnProperty.call(body, "issues_enabled")) entry.data.issues_enabled = body.issues_enabled !== false; + audit(entry, {type: "repo_update"}); + await saveRepo(entry); + return response(200, { + ok: true, + repo: entry.data.repo || body.repo, + description: entry.data.description, + default_branch: entry.data.default_branch, + visibility: entry.data.visibility, + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + }); + } + if (path === "/repo/rename" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const logical = String(body.logical || "").trim().replace(/^\/+|\/+$/g, ""); + if (!logical) throw new Error("logical repo name is required"); + const newRepo = {...(entry.data.repo || body.repo), logical}; + const newID = docID(newRepo); + if (entry.id !== newID) { + const existing = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: newID}}})); + if (existing.Item) throw Object.assign(new Error("target logical repo already exists"), {statusCode: 409}); + } + entry.data.repo = newRepo; + audit(entry, {type: "repo_rename", logical, user: key.user}); + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: newID}, data: {S: JSON.stringify(entry.data)}}})); + if (entry.id !== newID) { + await moveRepoRecords(entry.id, newID); + await deleteMembershipIndex({data: {...entry.data, repo: body.repo}}); + await db.send(new DeleteItemCommand({TableName: table, Key: {id: {S: entry.id}}})); + } + await syncMembershipIndex({id: newID, data: entry.data}); + return response(200, {ok: true, repo: newRepo}); + } + if (path === "/repo/delete" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const repo = await ensurePhysicalRepo(entry); + await deletePhysicalRepo(repo); + await deleteRepoMetadata(entry); + return response(200, {ok: true}); + } if (path === "/keys/list" && method === "POST") { const entry = await loadRepo(body.repo); requireAdmin(event, entry); @@ -628,40 +818,141 @@ Resources: await saveRepo(entry); return response(200, {ok: true}); } + if (path === "/keys/invite/create" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const user = String(body.user || "").trim(); + const role = normalizeRole(body.role || "read"); + if (!user) throw new Error("user is required"); + if (!validRole(role) || role === "owner") throw new Error("invalid role"); + const token = crypto.randomBytes(24).toString("base64url"); + const brokerURL = String(body.broker_url || "").trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || "") > Date.now()); + entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); + const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: "member_invite_create", user, role}); + await saveRepo(entry); + return response(200, {ok: true, code, accept_command: "bgit admin accept-invite " + code}); + } + if (path === "/keys/invite/accept" && method === "POST") { + const entry = await loadRepo(body.repo); + const signed = submittedSignedKey(event); + if (!signed) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = entry.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || "") > Date.now()); + if (!invite) throw Object.assign(new Error("invite is not pending or has expired"), {statusCode: 404}); + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + if (existing) { + existing.user = invite.user; + existing.role = invite.role; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user: invite.user, role: invite.role, public_key: signed.public_key, source: "invite", suspended: false}); + } + entry.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + audit(entry, {type: "member_invite_accept", user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + await saveRepo(entry); + return response(200, {ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + } if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { const entry = await loadRepo(body.repo); requireAdmin(event, entry); const key = String(body.key || "").trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); + const match = (k) => keyMatches(k, key); if (entry.data.keys.some((k) => match(k) && k.role === "owner")) throw Object.assign(new Error("owners cannot be removed or suspended"), {statusCode: 403}); - if (path === "/keys/remove") entry.data.keys = entry.data.keys.filter((k) => !match(k)); - else for (const item of entry.data.keys) if (match(item)) item.suspended = true; + let changed = false; + if (path === "/keys/remove") { + const before = entry.data.keys.length; + entry.data.keys = entry.data.keys.filter((k) => !match(k)); + changed = entry.data.keys.length !== before; + } else { + for (const item of entry.data.keys) { + if (match(item)) { + item.suspended = true; + changed = true; + } + } + } + if (!changed) throw Object.assign(new Error("key not found"), {statusCode: 404}); await saveRepo(entry); return response(200, {ok: true}); } - if (path === "/owners/transfer" && method === "POST") { + if (path === "/keys/unsuspend" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); - const target = String(body.key || "").trim(); - const normalized = normalizeKey(target); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(target); + requireAdmin(event, entry); + const key = String(body.key || "").trim(); + const match = (k) => keyMatches(k, key); let changed = false; - for (const item of entry.data.keys) { + for (const item of entry.data.keys || []) { if (match(item)) { - item.role = "owner"; - item.user = body.user || item.user; + item.suspended = false; changed = true; - } else if (item.role === "owner" && normalizeKey(item.public_key) === normalizeKey(key.public_key)) { - item.role = "admin"; } } - if (!changed) throw new Error("target key not found"); - audit(entry, {type: "owner_transfer", user: body.user || ""}); + if (!changed) throw Object.assign(new Error("key not found"), {statusCode: 404}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/owners/transfer/confirm" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { + throw Object.assign(new Error("ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it"), {statusCode: 409}); + } + const token = crypto.randomBytes(24).toString("base64url"); + const brokerURL = String(body.broker_url || "").trim(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + entry.data.owner_transfer = { + token_hash: ownershipTransferTokenHash(token), + requested_by: key.user || "", + requested_by_fingerprint: keyFingerprint(key.public_key), + broker_url: brokerURL, + expires_at: expires, + }; + const code = ownershipTransferCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: "owner_transfer_confirm", user: key.user || "", expires_at: expires}); + await saveRepo(entry); + return response(200, {ok: true, code, accept_command: "bgit admin accept-ownership-transfer " + code, cancel_command: "bgit admin cancel-ownership-transfer --broker " + brokerURL + " " + ((entry.data.repo || body.repo).logical || "")}); + } + if (path === "/owners/transfer/cancel" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + delete entry.data.owner_transfer; + audit(entry, {type: "owner_transfer_cancel", user: key.user || ""}); await saveRepo(entry); return response(200, {ok: true}); } + if (path === "/owners/transfer/accept" && method === "POST") { + const entry = await loadRepo(body.repo); + const transfer = entry.data.owner_transfer; + if (!transfer || ownershipTransferExpired(transfer)) throw Object.assign(new Error("ownership transfer is not pending or has expired"), {statusCode: 404}); + if (ownershipTransferTokenHash(body.token) !== transfer.token_hash) throw Object.assign(new Error("ownership transfer code is invalid"), {statusCode: 403}); + const accepted = submittedSignedKey(event); + if (!accepted) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const user = String(body.user || "owner").trim() || "owner"; + const ownerFingerprint = transfer.requested_by_fingerprint || ""; + for (const item of entry.data.keys || []) { + if (item.role === "owner" && keyFingerprint(item.public_key) === ownerFingerprint) item.role = "admin"; + } + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + if (existing) { + existing.role = "owner"; + existing.user = user; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user, role: "owner", public_key: accepted.public_key, source: "ownership-transfer", suspended: false}); + } + delete entry.data.owner_transfer; + audit(entry, {type: "owner_transfer_accept", user, fingerprint: accepted.fingerprint}); + await saveRepo(entry); + return response(200, {ok: true, user, fingerprint: accepted.fingerprint}); + } if (path === "/protection/list" && method === "POST") { const entry = await loadRepo(body.repo); requireAdmin(event, entry); @@ -684,6 +975,57 @@ Resources: await saveRepo(entry); return response(200, {ok: true}); } + if (path === "/issues/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const issues = await listIssues(entry); + return response(200, {issues}); + } + if (path === "/issues/view" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + return response(200, {issue}); + } + if (path === "/issues/create" && method === "POST") { + const entry = await loadRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const key = requireIssueCreate(event, entry); + const title = String(body.title || "").trim(); + const issueBody = String(body.body || "").trim(); + if (!title) throw new Error("issue title is required"); + const issue = {id: nextIssueID(entry.data), title, body: issueBody, status: "open", author: key.user || "anonymous", comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + await saveRepo(entry); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/comment" && method === "POST") { + const entry = await loadRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const key = requireIssueCreate(event, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw new Error("comment is required"); + issue.comments = issue.comments || []; + issue.comments.push({user: key.user || "anonymous", body: comment, at: new Date().toISOString()}); + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/close" || path === "/issues/reopen") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "write"); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + issue.status = path === "/issues/reopen" ? "open" : "closed"; + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } if (path === "/prs/create" && method === "POST") { const entry = await loadRepo(body.repo); const key = requireOperation(event, entry, "write"); @@ -748,6 +1090,7 @@ Resources: } if (path === "/prs/comment" && method === "POST") { const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); const key = requireOperation(event, entry, "read"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); @@ -763,6 +1106,7 @@ Resources: } if (path === "/prs/reply" && method === "POST") { const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); const key = requireOperation(event, entry, "read"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); @@ -797,6 +1141,7 @@ Resources: } if (path === "/prs/merge" && method === "POST") { const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); const key = requireOperation(event, entry, "merge"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); @@ -826,17 +1171,17 @@ Resources: const entry = await loadRepo(body.repo); const key = signedKey(event, entry); const operation = body.operation || ""; - const allowed = !!key && roleAllows(key.role, operation); - return response(200, {allowed, user: key && key.user, role: key && key.role}); + const allowed = (operation === "read" && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); + return response(200, {allowed, user: key && key.user || (allowed ? "anonymous" : ""), role: key && key.role || (allowed ? "read" : "")}); } if (path === "/auth/status" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); + const key = signedKey(event, entry) || (repoIsPublic(entry) ? anonymousKey() : null); if (!key) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); return response(200, { broker_version: brokerVersion, repo: entry.data.repo || body.repo, - identity: {user: key.user || "", source: key.source || "", key_fingerprint: keyFingerprint(key.public_key), public_key: key.public_key || ""}, + identity: {user: key.user || "", source: key.source || "", key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : "", public_key: key.public_key || ""}, user: key.user || "", role: key.role || "", capabilities: roleCapabilities(key.role || ""), diff --git a/broker/gcp/index.js b/broker/gcp/index.js index 6c2dc6d..fafc668 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -179,6 +179,37 @@ function signedKey(req, entry) { return key; } +function submittedSignedKey(req) { + const publicKey = normalizeKey(req.get('x-bgit-key')); + const message = String(req.get('x-bgit-signature-message') || ''); + const signature = String(req.get('x-bgit-signature') || ''); + if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; + const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; + const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; + if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; + return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; +} + +function ownershipTransferCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString('base64url'); + return 'bgitot_' + payload; +} + +function ownershipTransferTokenHash(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex'); +} + +function ownershipTransferExpired(transfer) { + return !transfer || !transfer.expires_at || Date.parse(transfer.expires_at) <= Date.now(); +} + +function memberInviteCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString('base64url'); + return 'bgitinv_' + payload; +} + function verifySignature(req, entry) { const adminKeys = (entry.data.keys || []).filter((k) => (k.role === 'admin' || k.role === 'owner') && !k.suspended); if (adminKeys.length === 0) return true; @@ -201,6 +232,16 @@ function keyFingerprint(publicKey) { return 'SHA256:' + crypto.createHash('sha256').update(data).digest('base64').replace(/=+$/g, ''); } +function keyMatches(item, key) { + const value = String(key || '').trim(); + if (!value) return false; + const normalized = normalizeKey(value); + return normalizeKey(item.public_key) === normalized || + item.public_key === value || + item.public_key.includes(value) || + keyFingerprint(item.public_key) === value; +} + function memberDocID(fingerprint) { return Buffer.from(String(fingerprint || '')).toString('base64url'); } @@ -221,6 +262,18 @@ function roleCapabilities(role) { }; } +function anonymousKey() { + return {user: 'anonymous', role: 'read', public_key: '', source: 'public', anonymous: true}; +} + +function repoIsPublic(entry) { + return (entry.data.visibility || 'private') === 'public'; +} + +function repoIsReadOnly(entry) { + return !!entry.data.read_only; +} + function validRole(role) { return ['owner', 'admin', 'maintainer', 'developer', 'triage', 'read'].includes(role); } @@ -239,6 +292,7 @@ function requireAdmin(req, entry) { function requireRead(req, entry) { const key = signedKey(req, entry); + if (!key && repoIsPublic(entry)) return anonymousKey(); if (!key || !roleAllows(key.role, 'read')) { const err = new Error('read SSH signature required'); err.status = 403; @@ -248,6 +302,11 @@ function requireRead(req, entry) { } function requireWrite(req, entry) { + if (repoIsReadOnly(entry)) { + const err = new Error('repository is read-only'); + err.status = 403; + throw err; + } const key = signedKey(req, entry); if (!key || !roleAllows(key.role, 'write')) { const err = new Error('write SSH signature required'); @@ -257,6 +316,12 @@ function requireWrite(req, entry) { return key; } +function requireIssueCreate(req, entry) { + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); + if (repoIsPublic(entry)) return signedKey(req, entry) || anonymousKey(); + return requireRead(req, entry); +} + function cleanObjectPath(value) { const path = String(value || '').replace(/^\/+/, ''); if (path.includes('\0') || path.includes('..')) throw new Error('invalid object path'); @@ -300,6 +365,18 @@ async function deleteObject(repo, objectPath) { await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).delete({ignoreNotFound: true}); } +async function deletePhysicalRepo(repo) { + if (!repo.bucket) return; + const bucket = storage.bucket(repo.bucket); + const [files] = await bucket.getFiles(); + await Promise.all(files.map((file) => file.delete({ignoreNotFound: true}))); + try { + await bucket.delete(); + } catch (err) { + if (err && err.code !== 404) throw err; + } +} + async function listObjects(repo, prefix) { const repoPrefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); const queryPrefix = objectName(repo, prefix); @@ -381,6 +458,67 @@ function nextPRID(data) { return data.next_pr_id++; } +function nextIssueID(data) { + data.next_issue_id = Number(data.next_issue_id || 1); + return data.next_issue_id++; +} + +function issueDoc(entry, id) { + return entry.ref.collection('issues').doc(String(id).padStart(10, '0')); +} + +async function saveIssue(entry, issue) { + await issueDoc(entry, issue.id).set(issue, {merge: false}); +} + +async function loadIssue(entry, id) { + const snap = await issueDoc(entry, id).get(); + if (!snap.exists) return null; + return snap.data() || null; +} + +async function listIssues(entry) { + const snap = await entry.ref.collection('issues').orderBy('id', 'desc').get(); + return snap.docs.map((doc) => doc.data() || {}); +} + +async function deleteRepoMetadata(entry) { + const prSnap = await entry.ref.collection('prs').get(); + const issueSnap = await entry.ref.collection('issues').get(); + const deletes = []; + prSnap.forEach((doc) => deletes.push(doc.ref.delete())); + issueSnap.forEach((doc) => deletes.push(doc.ref.delete())); + const repo = entry.data.repo || {}; + const oldRepoDocID = docID(repo); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + deletes.push(members.doc(memberDocID(keyFingerprint(key.public_key))).collection('repos').doc(oldRepoDocID).delete()); + } + deletes.push(entry.ref.delete()); + await Promise.all(deletes); +} + +async function moveRepoSubcollections(oldEntry, newRef) { + const copies = []; + for (const collectionName of ['prs', 'issues']) { + const snap = await oldEntry.ref.collection(collectionName).get(); + snap.forEach((doc) => { + copies.push(newRef.collection(collectionName).doc(doc.id).set(doc.data() || {}, {merge: false})); + }); + } + await Promise.all(copies); +} + +async function deleteMembershipIndex(entry) { + const repoDocID = docID(entry.data.repo || {}); + const deletes = []; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + deletes.push(members.doc(memberDocID(keyFingerprint(key.public_key))).collection('repos').doc(repoDocID).delete()); + } + await Promise.all(deletes); +} + function findPR(data, id) { return (data.prs || []).find((pr) => Number(pr.id) === Number(id)); } @@ -532,6 +670,73 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix})); return; } + if (req.path === '/repo/info' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify({ + repo: entry.data.repo || body.repo, + description: entry.data.description || '', + default_branch: entry.data.default_branch || 'main', + visibility: entry.data.visibility || 'private', + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + })); + return; + } + if (req.path === '/repo/update' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + if (Object.prototype.hasOwnProperty.call(body, 'description')) entry.data.description = String(body.description || '').trim(); + if (Object.prototype.hasOwnProperty.call(body, 'default_branch')) entry.data.default_branch = String(body.default_branch || '').trim() || 'main'; + if (Object.prototype.hasOwnProperty.call(body, 'visibility')) entry.data.visibility = body.visibility === 'public' ? 'public' : 'private'; + if (Object.prototype.hasOwnProperty.call(body, 'read_only')) entry.data.read_only = !!body.read_only; + if (Object.prototype.hasOwnProperty.call(body, 'issues_enabled')) entry.data.issues_enabled = body.issues_enabled !== false; + audit(entry, {type: 'repo_update'}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ + ok: true, + repo: entry.data.repo || body.repo, + description: entry.data.description, + default_branch: entry.data.default_branch, + visibility: entry.data.visibility, + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + })); + return; + } + if (req.path === '/repo/rename' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const logical = String(body.logical || '').trim().replace(/^\/+|\/+$/g, ''); + if (!logical) throw new Error('logical repo name is required'); + const newRepo = {...(entry.data.repo || body.repo), logical}; + const newRef = repos.doc(docID(newRepo)); + const oldID = docID(entry.data.repo || body.repo); + const newID = docID(newRepo); + if (oldID !== newID && (await newRef.get()).exists) throw Object.assign(new Error('target logical repo already exists'), {status: 409}); + entry.data.repo = newRepo; + audit(entry, {type: 'repo_rename', logical, user: key.user}); + await newRef.set(entry.data, {merge: false}); + if (oldID !== newID) { + await moveRepoSubcollections({ref: entry.ref, data: {...entry.data, repo: body.repo}}, newRef); + await deleteMembershipIndex({data: {...entry.data, repo: body.repo}}); + await entry.ref.delete(); + } + await syncMembershipIndex({ref: newRef, data: entry.data}); + res.status(200).send(JSON.stringify({ok: true, repo: newRepo})); + return; + } + if (req.path === '/repo/delete' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const repo = await ensurePhysicalRepo(entry); + await deletePhysicalRepo(repo); + await deleteRepoMetadata(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } if (req.path === '/keys/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); requireAdmin(req, entry); @@ -551,42 +756,148 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true})); return; } + if (req.path === '/keys/invite/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = String(body.user || '').trim(); + const role = normalizeRole(body.role || 'read'); + if (!user) throw new Error('user is required'); + if (!validRole(role) || role === 'owner') throw new Error('invalid role'); + const token = crypto.randomBytes(24).toString('base64url'); + const brokerURL = String(body.broker_url || '').trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || '') > Date.now()); + entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); + const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: 'member_invite_create', user, role}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-invite ' + code})); + return; + } + if (req.path === '/keys/invite/accept' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const signed = submittedSignedKey(req); + if (!signed) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = entry.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || '') > Date.now()); + if (!invite) throw Object.assign(new Error('invite is not pending or has expired'), {status: 404}); + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + if (existing) { + existing.user = invite.user; + existing.role = invite.role; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user: invite.user, role: invite.role, public_key: signed.public_key, source: 'invite', suspended: false}); + } + entry.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + audit(entry, {type: 'member_invite_accept', user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint})); + return; + } if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { const entry = await ensureRepo(body.repo); requireAdmin(req, entry); const key = String(body.key || '').trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); + const match = (k) => keyMatches(k, key); if (entry.data.keys.some((k) => match(k) && k.role === 'owner')) throw Object.assign(new Error('owners cannot be removed or suspended'), {status: 403}); - if (req.path === '/keys/remove') entry.data.keys = entry.data.keys.filter((k) => !match(k)); - else for (const item of entry.data.keys) if (match(item)) item.suspended = true; + let changed = false; + if (req.path === '/keys/remove') { + const before = entry.data.keys.length; + entry.data.keys = entry.data.keys.filter((k) => !match(k)); + changed = entry.data.keys.length !== before; + } else { + for (const item of entry.data.keys) { + if (match(item)) { + item.suspended = true; + changed = true; + } + } + } + if (!changed) throw Object.assign(new Error('key not found'), {status: 404}); await saveRepo(entry); res.status(200).send(JSON.stringify({ok: true})); return; } - if (req.path === '/owners/transfer' && req.method === 'POST') { + if (req.path === '/keys/unsuspend' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); - const target = String(body.key || '').trim(); - const normalized = normalizeKey(target); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(target); + requireAdmin(req, entry); + const key = String(body.key || '').trim(); + const match = (k) => keyMatches(k, key); let changed = false; - for (const item of entry.data.keys) { + for (const item of entry.data.keys || []) { if (match(item)) { - item.role = 'owner'; - item.user = body.user || item.user; + item.suspended = false; changed = true; - } else if (item.role === 'owner' && normalizeKey(item.public_key) === normalizeKey(key.public_key)) { - item.role = 'admin'; } } - if (!changed) throw new Error('target key not found'); - audit(entry, {type: 'owner_transfer', user: body.user || ''}); + if (!changed) throw Object.assign(new Error('key not found'), {status: 404}); await saveRepo(entry); res.status(200).send(JSON.stringify({ok: true})); return; } + if (req.path === '/owners/transfer/confirm' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { + throw Object.assign(new Error('ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it'), {status: 409}); + } + const token = crypto.randomBytes(24).toString('base64url'); + const brokerURL = String(body.broker_url || '').trim(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + entry.data.owner_transfer = { + token_hash: ownershipTransferTokenHash(token), + requested_by: key.user || '', + requested_by_fingerprint: keyFingerprint(key.public_key), + broker_url: brokerURL, + expires_at: expires, + }; + const code = ownershipTransferCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: 'owner_transfer_confirm', user: key.user || '', expires_at: expires}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-ownership-transfer ' + code, cancel_command: 'bgit admin cancel-ownership-transfer --broker ' + brokerURL + ' ' + ((entry.data.repo || body.repo).logical || '')})); + return; + } + if (req.path === '/owners/transfer/cancel' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + delete entry.data.owner_transfer; + audit(entry, {type: 'owner_transfer_cancel', user: key.user || ''}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/owners/transfer/accept' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const transfer = entry.data.owner_transfer; + if (!transfer || ownershipTransferExpired(transfer)) throw Object.assign(new Error('ownership transfer is not pending or has expired'), {status: 404}); + if (ownershipTransferTokenHash(body.token) !== transfer.token_hash) throw Object.assign(new Error('ownership transfer code is invalid'), {status: 403}); + const accepted = submittedSignedKey(req); + if (!accepted) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const user = String(body.user || 'owner').trim() || 'owner'; + const ownerFingerprint = transfer.requested_by_fingerprint || ''; + for (const item of entry.data.keys || []) { + if (item.role === 'owner' && keyFingerprint(item.public_key) === ownerFingerprint) item.role = 'admin'; + } + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + if (existing) { + existing.role = 'owner'; + existing.user = user; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user, role: 'owner', public_key: accepted.public_key, source: 'ownership-transfer', suspended: false}); + } + delete entry.data.owner_transfer; + audit(entry, {type: 'owner_transfer_accept', user, fingerprint: accepted.fingerprint}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, user, fingerprint: accepted.fingerprint})); + return; + } if (req.path === '/protection/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); requireAdmin(req, entry); @@ -612,6 +923,62 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true})); return; } + if (req.path === '/issues/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const issues = await listIssues(entry); + res.status(200).send(JSON.stringify({issues})); + return; + } + if (req.path === '/issues/view' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + res.status(200).send(JSON.stringify({issue})); + return; + } + if (req.path === '/issues/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const key = requireIssueCreate(req, entry); + const title = String(body.title || '').trim(); + const issueBody = String(body.body || '').trim(); + if (!title) throw new Error('issue title is required'); + const issue = {id: nextIssueID(entry.data), title, body: issueBody, status: 'open', author: key.user || 'anonymous', comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + await saveRepo(entry); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/comment' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const key = requireIssueCreate(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw new Error('comment is required'); + issue.comments = issue.comments || []; + issue.comments.push({user: key.user || 'anonymous', body: comment, at: new Date().toISOString()}); + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/close' || req.path === '/issues/reopen') { + const entry = await ensureRepo(body.repo); + requireWrite(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + issue.status = req.path === '/issues/reopen' ? 'open' : 'closed'; + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } if (req.path === '/prs/create' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const key = requireWrite(req, entry); @@ -682,6 +1049,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/comment' && req.method === 'POST') { const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); const key = requireRead(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); @@ -698,6 +1066,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/reply' && req.method === 'POST') { const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); const key = requireRead(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); @@ -734,6 +1103,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/merge' && req.method === 'POST') { const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); const key = signedKey(req, entry); if (!key || !roleAllows(key.role, 'merge')) throw Object.assign(new Error('merge SSH signature required'), {status: 403}); const pr = await loadPR(entry, body.id); @@ -765,18 +1135,18 @@ exports.broker = async (req, res) => { const entry = await ensureRepo(body.repo); const key = signedKey(req, entry); const operation = body.operation || ''; - const allowed = !!key && roleAllows(key.role, operation); - res.status(200).send(JSON.stringify({allowed, user: key && key.user, role: key && key.role})); + const allowed = (operation === 'read' && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); + res.status(200).send(JSON.stringify({allowed, user: key && key.user || (allowed ? 'anonymous' : ''), role: key && key.role || (allowed ? 'read' : '')})); return; } if (req.path === '/auth/status' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); + const key = signedKey(req, entry) || (repoIsPublic(entry) ? anonymousKey() : null); if (!key) throw Object.assign(new Error('SSH signature required'), {status: 403}); res.status(200).send(JSON.stringify({ broker_version: brokerVersion, repo: entry.data.repo || body.repo, - identity: {user: key.user || '', source: key.source || '', key_fingerprint: keyFingerprint(key.public_key), public_key: key.public_key || ''}, + identity: {user: key.user || '', source: key.source || '', key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : '', public_key: key.public_key || ''}, user: key.user || '', role: key.role || '', capabilities: roleCapabilities(key.role || ''), diff --git a/broker_commands.go b/broker_commands.go index 28e6611..8fd4c4e 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -3,6 +3,8 @@ package main import ( "bufio" "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -11,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" ) @@ -28,19 +31,25 @@ func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit admin keys|owner|protect|members [args]\n\nCloud IAM administration moved to bgit direct admin.") + return errors.New("usage: bgit admin keys|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer [args]\n\nCloud IAM administration moved to bgit direct admin.") } switch args[0] { case "keys": return brokerAdminKeysCommand(cfg, args[1:], stdin, stdout) case "repo": - return errors.New("repo registration happens during bgit init; use bgit admin keys for user/key administration") + return brokerAdminRepoCommand(cfg, args[1:], stdout) case "owner": return brokerOwnerCommand(cfg, args[1:], stdout) case "protect": return brokerProtectionCommand(cfg, args[1:], stdout) case "members": return brokerMembersCommand(cfg, args[1:], stdout) + case "confirm-ownership-transfer", "accept-ownership-transfer", "cancel-ownership-transfer": + return brokerOwnerCommand(cfg, args, stdout) + case "invite-user": + return brokerInviteUserCommand(cfg, args[1:], stdout) + case "accept-invite": + return brokerAcceptInviteCommand(args[1:], stdout) case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": return errors.New("cloud IAM administration moved to bgit direct admin") default: @@ -48,6 +57,83 @@ func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, std } } +type brokerRepoAdminRequest struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly *bool `json:"read_only,omitempty"` + IssuesEnabled *bool `json:"issues_enabled,omitempty"` + Logical string `json:"logical,omitempty"` +} + +func brokerAdminRepoCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin repo visibility|readonly|issues|rename|delete [args]") + } + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + switch args[0] { + case "visibility": + if len(args) != 2 || (args[1] != "public" && args[1] != "private") { + return errors.New("usage: bgit admin repo visibility public|private") + } + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), Visibility: args[1]} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository visibility to %s\n", args[1]) + return nil + case "readonly": + if len(args) != 2 || (args[1] != "on" && args[1] != "off") { + return errors.New("usage: bgit admin repo readonly on|off") + } + readOnly := args[1] == "on" + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), ReadOnly: &readOnly} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository read-only to %t\n", readOnly) + return nil + case "issues": + if len(args) != 2 || (args[1] != "on" && args[1] != "off") { + return errors.New("usage: bgit admin repo issues on|off") + } + issuesEnabled := args[1] == "on" + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), IssuesEnabled: &issuesEnabled} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository issues to %t\n", issuesEnabled) + return nil + case "rename": + if len(args) != 2 { + return errors.New("usage: bgit admin repo rename NEW_LOGICAL_NAME") + } + logical := logicalRepoWithGit(args[1]) + if err := brokerPost(cfg.brokerURL, "/repo/rename", brokerRepoAdminRequest{Repo: repoForBroker(cfg), Logical: logical}, nil); err != nil { + return err + } + _, _ = runGit(".", "config", "--local", "bucketgit.logicalRepo", logical) + _, _ = runGit(".", "remote", "set-url", "origin", "git@"+defaultSSHHost+":"+logical) + fmt.Fprintf(stdout, "renamed repository to %s\n", logicalRepoDisplayName(logical)) + return nil + case "delete": + if len(args) != 2 || args[1] != "--yes" { + return errors.New("usage: bgit admin repo delete --yes") + } + if err := brokerPost(cfg.brokerURL, "/repo/delete", brokerRepoAdminRequest{Repo: repoForBroker(cfg)}, nil); err != nil { + return err + } + fmt.Fprintln(stdout, "deleted repository") + return nil + default: + return fmt.Errorf("unknown repo admin command %q", args[0]) + } +} + func brokerMembersCommand(cfg config, args []string, stdout io.Writer) error { if len(args) != 1 || args[0] != "reindex" { return errors.New("usage: bgit admin members reindex") @@ -377,51 +463,239 @@ func configForBrokerCommand(base config) (config, error) { } type brokerOwnerTransferRequest struct { - Repo brokerRepo `json:"repo"` - User string `json:"user"` - Key string `json:"key"` + Repo brokerRepo `json:"repo"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + BrokerURL string `json:"broker_url,omitempty"` + Token string `json:"token,omitempty"` +} + +type brokerOwnerTransferResponse struct { + Code string `json:"code"` + AcceptCommand string `json:"accept_command"` + CancelCommand string `json:"cancel_command"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type ownerTransferCodePayload struct { + BrokerURL string `json:"broker_url"` + Repo brokerRepo `json:"repo"` + Token string `json:"token"` } func brokerOwnerCommand(cfg config, args []string, stdout io.Writer) error { - if len(args) == 0 || args[0] != "transfer" { - return errors.New("usage: bgit admin owner transfer --user USER KEY_OR_FINGERPRINT") + if len(args) == 0 { + return errors.New("usage: bgit admin confirm-ownership-transfer --broker URL REPO\n bgit admin accept-ownership-transfer CODE\n bgit admin cancel-ownership-transfer [--broker URL REPO]") } - cfg, err := configForBrokerCommand(cfg) + switch args[0] { + case "transfer": + return errors.New("bgit admin owner transfer was replaced by bgit admin confirm-ownership-transfer") + case "confirm-ownership-transfer": + return brokerConfirmOwnershipTransferCommand(cfg, args[1:], stdout) + case "accept-ownership-transfer": + return brokerAcceptOwnershipTransferCommand(args[1:], stdout) + case "cancel-ownership-transfer": + return brokerCancelOwnershipTransferCommand(cfg, args[1:], stdout) + default: + return fmt.Errorf("unknown owner command %q", args[0]) + } +} + +func brokerConfirmOwnershipTransferCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, err := parseOwnershipTransferTarget(cfg, args, true) + if err != nil { + return err + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/owners/transfer/confirm", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "ownership transfer pending for %s\n\nGive this command to the new owner:\n %s\n\nCancel with:\n %s\n", repo.Logical, resp.AcceptCommand, resp.CancelCommand) + return nil +} + +func brokerAcceptOwnershipTransferCommand(args []string, stdout io.Writer) error { + if len(args) != 1 { + return errors.New("usage: bgit admin accept-ownership-transfer CODE") + } + payload, err := parseOwnershipTransferCode(args[0]) + if err != nil { + return err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(payload.BrokerURL, "/owners/transfer/accept", brokerOwnerTransferRequest{Repo: payload.Repo, Token: payload.Token, User: "owner"}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "accepted ownership for %s with key %s\n", payload.Repo.Logical, resp.Fingerprint) + return nil +} + +func brokerCancelOwnershipTransferCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, err := parseOwnershipTransferTarget(cfg, args, false) if err != nil { return err } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + if err := brokerPost(brokerURL, "/owners/transfer/cancel", brokerOwnerTransferRequest{Repo: repo}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "cancelled pending ownership transfer for %s\n", repo.Logical) + return nil +} + +func parseOwnershipTransferTarget(cfg config, args []string, requireBroker bool) (string, string, error) { + brokerURL := "" + repoName := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", err + } + brokerURL = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return "", "", fmt.Errorf("unsupported ownership transfer option %s", arg) + } + if repoName != "" { + return "", "", errors.New("ownership transfer accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" && !requireBroker { + if local, err := configForBrokerCommand(cfg); err == nil { + brokerURL = local.brokerURL + if repoName == "" { + repoName = local.logicalRepo + } + } + } + if brokerURL == "" { + return "", "", errors.New("ownership transfer requires --broker URL") + } + if repoName == "" { + return "", "", errors.New("ownership transfer requires a repository name") + } + return brokerURL, repoName, nil +} + +func parseOwnershipTransferCode(code string) (ownerTransferCodePayload, error) { + code = strings.TrimSpace(code) + if !strings.HasPrefix(code, "bgitot_") { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + raw := strings.TrimPrefix(code, "bgitot_") + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + var payload ownerTransferCodePayload + if err := json.Unmarshal(data, &payload); err != nil { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + if strings.TrimSpace(payload.BrokerURL) == "" || strings.TrimSpace(payload.Token) == "" || strings.TrimSpace(payload.Repo.Logical) == "" { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + return payload, nil +} + +func brokerInviteUserCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL := "" + repoName := "" user := "" - key := "" - for i := 1; i < len(args); i++ { + role := "read" + var err error + for i := 0; i < len(args); i++ { arg := args[i] name, value, hasValue := strings.Cut(arg, "=") switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + brokerURL = strings.TrimSpace(value) case "--user": value, i, err = optionValue(args, i, hasValue, value, name) if err != nil { return err } - user = value + user = strings.TrimSpace(value) + case "--role": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + role = normalizeBrokerRole(value) default: if strings.HasPrefix(arg, "-") { - return fmt.Errorf("unsupported owner transfer option %s", arg) + return fmt.Errorf("unsupported invite-user option %s", arg) } - if key != "" { - return errors.New("owner transfer accepts exactly one key") + if repoName != "" { + return errors.New("invite-user accepts exactly one repository") } - key = arg + repoName = strings.TrimSpace(arg) } } - if user == "" || key == "" { - return errors.New("usage: bgit admin owner transfer --user USER KEY_OR_FINGERPRINT") + if brokerURL == "" || repoName == "" || user == "" { + return errors.New("usage: bgit admin invite-user --broker URL --user USER [--role ROLE] REPO") + } + if !validBrokerRole(role) || role == "owner" { + return fmt.Errorf("invalid role %q", role) } - if err := brokerPost(cfg.brokerURL, "/owners/transfer", brokerOwnerTransferRequest{Repo: repoForBroker(cfg), User: user, Key: key}, nil); err != nil { + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/keys/invite/create", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL, User: user, Role: role}, &resp); err != nil { return err } - fmt.Fprintf(stdout, "transferred owner role to %s\n", user) + fmt.Fprintf(stdout, "invite pending for %s as %s on %s\n\nGive this command to the user:\n %s\n", user, role, repo.Logical, resp.AcceptCommand) return nil } +func brokerAcceptInviteCommand(args []string, stdout io.Writer) error { + if len(args) != 1 { + return errors.New("usage: bgit admin accept-invite CODE") + } + payload, err := parseInviteCode(args[0]) + if err != nil { + return err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(payload.BrokerURL, "/keys/invite/accept", brokerOwnerTransferRequest{Repo: payload.Repo, Token: payload.Token}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "accepted invite for %s as %s with key %s\n", resp.User, resp.Role, resp.Fingerprint) + return nil +} + +func parseInviteCode(code string) (ownerTransferCodePayload, error) { + code = strings.TrimSpace(code) + if !strings.HasPrefix(code, "bgitinv_") { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + raw := strings.TrimPrefix(code, "bgitinv_") + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + var payload ownerTransferCodePayload + if err := json.Unmarshal(data, &payload); err != nil { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + if strings.TrimSpace(payload.BrokerURL) == "" || strings.TrimSpace(payload.Token) == "" || strings.TrimSpace(payload.Repo.Logical) == "" { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + return payload, nil +} + type brokerProtectionRequest struct { Repo brokerRepo `json:"repo"` Ref string `json:"ref"` @@ -556,6 +830,121 @@ type brokerPullRequestRequest struct { TargetCommentID int `json:"target_comment_id,omitempty"` } +func issueCommand(args []string, stdin io.Reader, stdout io.Writer) error { + _ = stdin + if len(args) == 0 { + return errors.New("usage: bgit issue list|create|view|comment|close|reopen [args]") + } + cfg, err := configForBrokerCommand(config{}) + if err != nil { + return err + } + switch args[0] { + case "list": + var resp struct { + Issues []brokerIssue `json:"issues"` + } + if err := brokerPost(cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, issue := range resp.Issues { + fmt.Fprintf(stdout, "#%d\t%s\t%s\n", issue.ID, firstNonEmpty(issue.Status, "open"), issue.Title) + } + return nil + case "create": + title := "" + body := "" + for i := 1; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--body": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + body = value + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported issue create option %s", arg) + } + if title != "" { + title += " " + } + title += arg + } + } + if strings.TrimSpace(title) == "" { + return errors.New("usage: bgit issue create TITLE [--body BODY]") + } + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPost(cfg.brokerURL, "/issues/create", brokerIssueRequest{Repo: repoForBroker(cfg), Title: title, Body: body}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "created issue #%d\n", resp.Issue.ID) + return nil + case "view": + id, err := parseIssueIDArg(args) + if err != nil { + return err + } + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPost(cfg.brokerURL, "/issues/view", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "#%d %s\n%s\n\n%s\n", resp.Issue.ID, resp.Issue.Title, firstNonEmpty(resp.Issue.Status, "open"), resp.Issue.Body) + for _, comment := range resp.Issue.Comments { + fmt.Fprintf(stdout, "\n%s commented:\n%s\n", firstNonEmpty(comment.User, "anonymous"), comment.Body) + } + return nil + case "comment": + if len(args) < 3 { + return errors.New("usage: bgit issue comment ID COMMENT") + } + id, err := strconv.Atoi(strings.TrimPrefix(args[1], "#")) + if err != nil || id <= 0 { + return errors.New("issue id is required") + } + comment := strings.Join(args[2:], " ") + if err := brokerPost(cfg.brokerURL, "/issues/comment", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "commented on issue #%d\n", id) + return nil + case "close", "reopen": + id, err := parseIssueIDArg(args) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/issues/"+args[0], brokerIssueRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + verb := "closed" + if args[0] == "reopen" { + verb = "reopened" + } + fmt.Fprintf(stdout, "%s issue #%d\n", verb, id) + return nil + default: + return fmt.Errorf("unknown issue command %q", args[0]) + } +} + +func parseIssueIDArg(args []string) (int, error) { + if len(args) != 2 { + return 0, errors.New("issue id is required") + } + id, err := strconv.Atoi(strings.TrimPrefix(args[1], "#")) + if err != nil || id <= 0 { + return 0, errors.New("issue id is required") + } + return id, nil +} + func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { _ = stdin if len(args) == 0 { @@ -1146,6 +1535,21 @@ func defaultInitRepoName() string { return name } +func logicalRepoWithGit(name string) string { + name = strings.Trim(strings.TrimSpace(name), "/") + if name == "" { + return "repo.git" + } + if !strings.HasSuffix(name, ".git") { + name += ".git" + } + return name +} + +func logicalRepoDisplayName(name string) string { + return strings.TrimSuffix(strings.Trim(strings.TrimSpace(name), "/"), ".git") +} + func initDialogInitialState(target string, global globalConfig, repoName, profileName string) initDialogConfig { initial := initDialogConfig{ RepoName: firstNonEmpty(strings.TrimSpace(repoName), defaultInitRepoName()), diff --git a/main.go b/main.go index 366393f..4ec5a97 100644 --- a/main.go +++ b/main.go @@ -146,6 +146,13 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } return prCommand(cmdArgs, stdin, stdout) } + if cmd == "issue" || cmd == "issues" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + return issueCommand(cmdArgs, stdin, stdout) + } if cmd == "web" { return webCommand(context.Background(), cfg, cmdArgs, stdout) } @@ -584,6 +591,10 @@ func readLocalConfig(dir string) (config, error) { if logicalOut, logicalErr := runGit(dir, "config", "--get", "bucketgit.logicalRepo"); logicalErr == nil { logicalRepo = strings.Trim(strings.TrimSpace(string(logicalOut)), "/") } + localRegion := "" + if regionOut, regionErr := runGit(dir, "config", "--get", "bucketgit.region"); regionErr == nil { + localRegion = strings.TrimSpace(string(regionOut)) + } localProvider := "" if providerOut, providerErr := runGit(dir, "config", "--get", "bucketgit.provider"); providerErr == nil { localProvider = strings.TrimSpace(string(providerOut)) @@ -598,6 +609,7 @@ func readLocalConfig(dir string) (config, error) { origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo), brokerURL: brokerURL, logicalRepo: logicalRepo, + region: localRegion, identity: identity, auth: localAuth.auth, gcloudConfiguration: localAuth.gcloudConfiguration, @@ -610,6 +622,7 @@ func readLocalConfig(dir string) (config, error) { cfg, _, err := parseRepoURI(origin) if err == nil { cfg.branch = branch + cfg.region = localRegion cfg.auth = localAuth.auth cfg.gcloudConfiguration = localAuth.gcloudConfiguration return cfg, nil @@ -624,6 +637,7 @@ func readLocalConfig(dir string) (config, error) { cfg, _, parseErr := parseRepoURI(origin) if parseErr == nil { cfg.branch = branch + cfg.region = localRegion cfg.auth = localAuth.auth cfg.gcloudConfiguration = localAuth.gcloudConfiguration return cfg, nil @@ -646,6 +660,7 @@ func readLocalConfig(dir string) (config, error) { origin: originForConfig(config{provider: provider, bucket: bucket, prefix: prefix}), brokerURL: brokerURL, logicalRepo: logicalRepo, + region: localRegion, auth: localAuth.auth, gcloudConfiguration: localAuth.gcloudConfiguration, }, nil @@ -873,6 +888,7 @@ collaborate push Update remote refs and upload objects ls-remote List remote refs pr Create, review, merge, and close pull requests + issue Create, comment on, close, and reopen issues administer whoami Show broker identity, role, and capabilities for this repo @@ -1009,8 +1025,17 @@ Configure a direct bucketgit origin using Git remote syntax. `, "admin": `usage: bgit admin keys list|add|remove|suspend|import-github [args] - bgit admin owner transfer --user USER KEY_OR_FINGERPRINT + bgit admin invite-user --broker URL --user USER [--role ROLE] REPO + bgit admin accept-invite CODE + bgit admin confirm-ownership-transfer --broker URL REPO + bgit admin accept-ownership-transfer CODE + bgit admin cancel-ownership-transfer [--broker URL REPO] bgit admin protect add|list|remove [ref] + bgit admin repo visibility public|private + bgit admin repo readonly on|off + bgit admin repo issues on|off + bgit admin repo rename NEW_LOGICAL_NAME + bgit admin repo delete --yes Broker-backed repository administration. Cloud IAM and bucket-policy administration moved to bgit direct admin. @@ -1019,8 +1044,21 @@ examples: bgit admin keys list bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub bgit admin keys import-github octocat --role read + bgit admin invite-user --broker https://broker.example.com --user ada --role developer app bgit admin protect add main + bgit admin repo visibility public bgit direct admin grant-read user:dev@example.com +`, + "issue": `usage: + bgit issue list + bgit issue create TITLE [--body BODY] + bgit issue view ID + bgit issue comment ID COMMENT + bgit issue close ID + bgit issue reopen ID + +Broker-backed repository issues. Public repositories allow anonymous issue +creation; private repositories require membership. `, "pr": `usage: bgit pr create [--title TITLE] [--body BODY] [--source BRANCH] [--target BRANCH] diff --git a/main_test.go b/main_test.go index 9c5e341..fac282c 100644 --- a/main_test.go +++ b/main_test.go @@ -1052,6 +1052,9 @@ func TestProvisionAWSBrokerURLDeploysThenDiscoversStackOutput(t *testing.T) { bin := t.TempDir() marker := filepath.Join(t.TempDir(), "deployed") writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "sts get-caller-identity", stdout: `{"Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dennis"}`}, + {match: "s3api head-bucket --bucket bgit-broker-artifacts-123456789012-eu-west-1", exitCode: 1}, + {match: "s3api create-bucket --bucket bgit-broker-artifacts-123456789012-eu-west-1"}, {match: "cloudformation describe-stacks", stdout: "https://bgit-broker-provisioned-aws.example.test", requireFile: marker, exitCode: 1}, {match: "cloudformation deploy", touch: marker}, }) diff --git a/setup.go b/setup.go index 465141c..65a72c0 100644 --- a/setup.go +++ b/setup.go @@ -22,7 +22,7 @@ import ( "golang.org/x/term" ) -const setupProbeTimeout = 2 * time.Second +const setupProbeTimeout = 10 * time.Second const setupDialogProfilesPerProvider = 10 const setupRegionDialogItemsPerPage = 10 diff --git a/ssh.go b/ssh.go index 9df39ff..61e3db8 100644 --- a/ssh.go +++ b/ssh.go @@ -740,6 +740,8 @@ func brokerPostContext(ctx context.Context, brokerURL, path string, req any, res headerSets := brokerSignatureHeaderSetsForBroker(brokerURL, data) if len(headerSets) == 0 { headerSets = []map[string]string{{}} + } else { + headerSets = append(headerSets, map[string]string{}) } var lastErr error for i, headers := range headerSets { @@ -1320,10 +1322,15 @@ func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( fmt.Fprintf(stdout, " with profile %s", strings.TrimSpace(cfg.gcloudConfiguration)) } fmt.Fprintln(stdout) + s3Bucket, err := ensureAWSBrokerDeploymentBucket(cfg, region, stdout) + if err != nil { + return "", err + } args := []string{ "cloudformation", "deploy", "--stack-name", "bgit-broker", "--template-file", templatePath, + "--s3-bucket", s3Bucket, "--capabilities", "CAPABILITY_NAMED_IAM", "--region", region, } @@ -1334,6 +1341,32 @@ func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( return discoverAWSBrokerURL(cfg, opts) } +func ensureAWSBrokerDeploymentBucket(cfg config, region string, stdout io.Writer) (string, error) { + accountID, _ := awsCallerIdentity(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration)) + if accountID == "" { + return "", errors.New("discover AWS account id for broker deployment bucket") + } + bucket := fmt.Sprintf("bgit-broker-artifacts-%s-%s", accountID, region) + headArgs := []string{"s3api", "head-bucket", "--bucket", bucket, "--region", region} + if err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), headArgs...).Run(); err == nil { + return bucket, nil + } + fmt.Fprintf(stdout, "creating AWS broker deployment bucket %s in %s\n", bucket, region) + createArgs := []string{"s3api", "create-bucket", "--bucket", bucket, "--region", region} + if region != "us-east-1" { + createArgs = append(createArgs, "--create-bucket-configuration", "LocationConstraint="+region) + } + out, err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), createArgs...).CombinedOutput() + if err != nil { + text := strings.TrimSpace(string(out)) + if strings.Contains(text, "BucketAlreadyOwnedByYou") || strings.Contains(text, "BucketAlreadyExists") { + return bucket, nil + } + return "", fmt.Errorf("create AWS broker deployment bucket %s: %w\n%s", bucket, err, text) + } + return bucket, nil +} + func appendAWSProfile(args []string, profile string) []string { if strings.TrimSpace(profile) == "" { return args diff --git a/web.go b/web.go index ce0dcc4..ecc1279 100644 --- a/web.go +++ b/web.go @@ -26,6 +26,8 @@ import ( "sync" "time" "unicode/utf8" + + "golang.org/x/crypto/ssh" ) //go:embed www/* @@ -158,6 +160,31 @@ type webPullRequestCache struct { PRs []brokerPullRequest `json:"prs"` } +type brokerIssue struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Comments []brokerIssueReply `json:"comments,omitempty"` +} + +type brokerIssueReply struct { + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + At string `json:"at,omitempty"` +} + +type brokerIssueRequest struct { + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Comment string `json:"comment,omitempty"` +} + func webCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { opts, err := parseWebArgs(args) if err != nil { @@ -387,6 +414,10 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleAPIActionPull(ctx, w, r) case route == "api/actions/pr": s.handleAPIActionPullRequest(ctx, w, r) + case route == "api/actions/issues": + s.handleAPIActionIssue(ctx, w, r) + case route == "api/actions/settings": + s.handleAPIActionSettings(ctx, w, r) case route == "api/diff": s.handleAPIDiff(ctx, w, r) case route == "api/refs": @@ -397,6 +428,10 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { srv.handleAPICommits(ctx, w, r) case route == "api/prs": s.handleAPIPullRequests(ctx, w, r) + case route == "api/issues": + s.handleAPIIssues(ctx, w, r) + case route == "api/settings": + s.handleAPISettings(ctx, w, r) case route == "api/blob": srv.handleAPIBlob(ctx, w, r) case strings.HasPrefix(route, "api/commit/"): @@ -409,6 +444,12 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handlePullRequests(ctx, w, r) case strings.HasPrefix(route, "prs/"): s.handlePullRequest(ctx, w, r, strings.TrimPrefix(route, "prs/")) + case route == "issues": + s.handleIssues(ctx, w, r) + case strings.HasPrefix(route, "issues/"): + s.handleIssue(ctx, w, r, strings.TrimPrefix(route, "issues/")) + case route == "settings": + s.handleSettings(ctx, w, r) case route == "archive.zip": srv.handleArchiveZip(ctx, w, r) case strings.HasPrefix(route, "commit/"): @@ -675,6 +716,166 @@ func (s *webServer) handleAPIPullRequests(ctx context.Context, w http.ResponseWr }) } +type webSettingsInfo struct { + Repo brokerRepo `json:"repo"` + Title string `json:"title"` + BrokerURL string `json:"broker_url,omitempty"` + Provider string `json:"provider,omitempty"` + Region string `json:"region,omitempty"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + IssuesEnabled bool `json:"issues_enabled"` + Keys []brokerKey `json:"keys,omitempty"` + Protections []brokerProtectionRequest `json:"protections,omitempty"` + Errors map[string]string `json:"errors,omitempty"` +} + +type brokerRepoInfoRequest struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + IssuesEnabled bool `json:"issues_enabled"` + Logical string `json:"logical,omitempty"` +} + +type brokerRepoInfoResponse struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + ReadOnly bool `json:"read_only"` + IssuesEnabled bool `json:"issues_enabled"` +} + +func (s *webServer) handleAPISettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.renderJSON(w, s.settingsInfo(ctx)) +} + +func (s *webServer) handleAPIIssues(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + issues, err := s.listIssues(ctx) + if err != nil { + s.renderJSONError(w, http.StatusForbidden, err) + return + } + s.renderJSON(w, map[string]any{"issues": issues}) +} + +func (s *webServer) listIssues(ctx context.Context) ([]brokerIssue, error) { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return nil, errors.New("broker issues unavailable") + } + var resp struct { + Issues []brokerIssue `json:"issues"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(s.cfg)}, &resp); err != nil { + return nil, err + } + return resp.Issues, nil +} + +func (s *webServer) getIssue(ctx context.Context, id int) (brokerIssue, error) { + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/view", brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: id}, &resp); err != nil { + return brokerIssue{}, err + } + return resp.Issue, nil +} + +func (s *webServer) settingsInfo(ctx context.Context) webSettingsInfo { + info := webSettingsInfo{ + Repo: repoForBroker(s.cfg), + Title: s.title, + BrokerURL: s.cfg.brokerURL, + Provider: s.cfg.provider, + Region: firstNonEmpty(s.cfg.region, globalConfigRegionForBrokerURL(s.cfg.brokerURL)), + DefaultBranch: defaultBranch, + Visibility: "private", + IssuesEnabled: true, + Errors: map[string]string{}, + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return info + } + var repoInfo brokerRepoInfoResponse + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/repo/info", brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)}, &repoInfo); err != nil { + info.Errors["repo"] = err.Error() + } else { + if repoInfo.Repo.Logical != "" || repoInfo.Repo.Bucket != "" { + info.Repo = repoInfo.Repo + } + info.Description = repoInfo.Description + info.DefaultBranch = firstNonEmpty(repoInfo.DefaultBranch, defaultBranch) + info.Visibility = firstNonEmpty(repoInfo.Visibility, "private") + info.ReadOnly = repoInfo.ReadOnly + info.IssuesEnabled = repoInfo.IssuesEnabled + } + if keys, err := brokerListKeys(s.cfg.brokerURL, s.cfg); err != nil { + info.Errors["members"] = err.Error() + } else { + info.Keys = keys + } + var protections struct { + Protections []brokerProtectionRequest `json:"protections"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/protection/list", brokerProtectionRequest{Repo: repoForBroker(s.cfg)}, &protections); err != nil { + info.Errors["protections"] = err.Error() + } else { + info.Protections = protections.Protections + } + if len(info.Errors) == 0 { + info.Errors = nil + } + return info +} + +func globalConfigRegionForBrokerURL(brokerURL string) string { + want := normalizeBrokerURLForCompare(brokerURL) + if want == "" { + return "" + } + path, err := defaultGlobalConfigPath() + if err != nil { + return "" + } + global, err := readGlobalConfig(path) + if err != nil { + return "" + } + for _, profile := range global.GCPProfiles { + for _, region := range profile.Regions { + if normalizeBrokerURLForCompare(region.BrokerURL) == want { + return region.Name + } + } + } + for _, profile := range global.AWSProfiles { + for _, region := range profile.Regions { + if normalizeBrokerURLForCompare(region.BrokerURL) == want { + return region.Name + } + } + } + return "" +} + +func normalizeBrokerURLForCompare(value string) string { + return strings.TrimRight(strings.TrimSpace(value), "/") +} + func (s *webServer) handleAPICommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { hash = strings.TrimSpace(strings.Trim(hash, "/")) if hash == "" { @@ -1137,6 +1338,179 @@ func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.Respo s.renderJSON(w, map[string]any{"ok": true, "pr": resp.PR, "prs": webAPIPullRequests(prs)}) } +func (s *webServer) handleAPIActionSettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("settings require a broker-backed repository")) + return + } + var req struct { + Action string `json:"action"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + ReadOnly bool `json:"read_only"` + IssuesEnabled bool `json:"issues_enabled"` + Logical string `json:"logical"` + User string `json:"user"` + Role string `json:"role"` + PublicKey string `json:"public_key"` + Key string `json:"key"` + Ref string `json:"ref"` + RequirePR bool `json:"require_pr"` + AllowOverrides bool `json:"allow_overrides"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + endpoint := "" + var payload any + switch strings.TrimSpace(req.Action) { + case "update-repo": + endpoint = "/repo/update" + payload = brokerRepoInfoRequest{ + Repo: repoForBroker(s.cfg), + Description: req.Description, + DefaultBranch: req.DefaultBranch, + Visibility: req.Visibility, + ReadOnly: req.ReadOnly, + IssuesEnabled: req.IssuesEnabled, + } + case "add-member": + user := strings.TrimSpace(req.User) + if user == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("user is required")) + return + } + role := normalizeBrokerRole(req.Role) + if !validBrokerRole(role) || role == "owner" { + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("invalid role %q", req.Role)) + return + } + endpoint = "/keys/invite/create" + payload = brokerOwnerTransferRequest{Repo: repoForBroker(s.cfg), User: user, Role: role, BrokerURL: s.cfg.brokerURL} + case "remove-member": + endpoint = "/keys/remove" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "suspend-member": + endpoint = "/keys/suspend" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "unsuspend-member": + endpoint = "/keys/unsuspend" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "transfer-owner": + endpoint = "/owners/transfer/confirm" + payload = brokerOwnerTransferRequest{Repo: repoForBroker(s.cfg), BrokerURL: s.cfg.brokerURL} + case "repo-rename": + endpoint = "/repo/rename" + payload = brokerRepoInfoRequest{Repo: repoForBroker(s.cfg), Logical: logicalRepoWithGit(req.Logical)} + case "repo-delete": + endpoint = "/repo/delete" + payload = brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)} + case "protect-upsert": + ref := normalizeDestinationRef(firstNonEmpty(strings.TrimSpace(req.Ref), defaultBranch)) + endpoint = "/protection/upsert" + payload = brokerProtectionRequest{Repo: repoForBroker(s.cfg), Ref: ref, RequirePR: req.RequirePR, AllowOverrides: req.AllowOverrides} + case "protect-remove": + endpoint = "/protection/remove" + payload = brokerProtectionRequest{Repo: repoForBroker(s.cfg), Ref: normalizeDestinationRef(req.Ref)} + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported settings action %q", req.Action)) + return + } + if endpoint != "/repo/update" { + switch p := payload.(type) { + case brokerKeyRequest: + if strings.TrimSpace(p.Key) == "" && len(p.PublicKeys) == 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("member key is required")) + return + } + case brokerOwnerTransferRequest: + if endpoint == "/keys/invite/create" && strings.TrimSpace(p.User) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("member invite requires user")) + return + } + case brokerProtectionRequest: + if strings.TrimSpace(p.Ref) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("branch protection ref is required")) + return + } + case brokerRepoInfoRequest: + if endpoint == "/repo/rename" && strings.TrimSpace(p.Logical) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("logical repo name is required")) + return + } + } + } + var brokerResp map[string]any + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, payload, &brokerResp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if endpoint == "/repo/rename" && strings.TrimSpace(req.Logical) != "" { + logical := logicalRepoWithGit(req.Logical) + _, _ = runGit(".", "config", "--local", "bucketgit.logicalRepo", logical) + _, _ = runGit(".", "remote", "set-url", "origin", "git@"+defaultSSHHost+":"+logical) + s.cfg.logicalRepo = logical + } + s.renderJSON(w, map[string]any{"ok": true, "settings": s.settingsInfo(ctx), "broker": brokerResp}) +} + +func (s *webServer) handleAPIActionIssue(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issues require a broker-backed repository")) + return + } + var req struct { + Action string `json:"action"` + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Comment string `json:"comment"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + endpoint := "" + payload := brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: req.ID, Title: strings.TrimSpace(req.Title), Body: strings.TrimSpace(req.Body), Comment: strings.TrimSpace(req.Comment)} + switch strings.TrimSpace(req.Action) { + case "create": + endpoint = "/issues/create" + if payload.Title == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issue title is required")) + return + } + case "comment": + endpoint = "/issues/comment" + if payload.ID <= 0 || payload.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issue comment requires an issue and comment")) + return + } + case "close": + endpoint = "/issues/close" + case "reopen": + endpoint = "/issues/reopen" + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported issue action %q", req.Action)) + return + } + var resp map[string]any + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, payload, &resp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, map[string]any{"ok": true}) +} + func (s *webServer) webRepositoryState(ctx context.Context, refreshRemote bool, selectedRef string) (webAPIState, error) { localRepo, err := openLocalRepository(".") if err != nil { @@ -1672,6 +2046,220 @@ func (s *webServer) handlePullRequests(ctx context.Context, w http.ResponseWrite s.renderPage(w, webPageTitle(s.title, "pull requests"), body.String()) } +func (s *webServer) handleIssues(ctx context.Context, w http.ResponseWriter, r *http.Request) { + issues, err := s.listIssues(ctx) + if err != nil { + issues = nil + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
    `) + body.WriteString(s.headerHTML(ref, "issues")) + body.WriteString(`
    Issues
    `) + body.WriteString(issueListHTML(issues)) + body.WriteString(`

    New issue

    `) + if err != nil { + body.WriteString(`
    ` + html.EscapeString(err.Error()) + `
    `) + } + body.WriteString(`
    `) + s.renderPage(w, webPageTitle(s.title, "issues"), body.String()) +} + +func (s *webServer) handleIssue(ctx context.Context, w http.ResponseWriter, r *http.Request, idPart string) { + id, err := strconv.Atoi(strings.Trim(idPart, "/")) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + issue, err := s.getIssue(ctx, id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
    `) + body.WriteString(s.headerHTML(ref, "issues")) + body.WriteString(`
    `) + body.WriteString(`
    ` + html.EscapeString(strings.ToUpper(firstNonEmpty(issue.Status, "open"))) + `

    ` + html.EscapeString(issue.Title) + `

    `) + body.WriteString(`
    ` + html.EscapeString(firstNonEmpty(issue.Author, "anonymous")) + ` opened ` + html.EscapeString(relativeTime(parseTime(issue.CreatedAt))) + `

    ` + html.EscapeString(issue.Body) + `

    `) + for _, comment := range issue.Comments { + body.WriteString(`
    ` + html.EscapeString(firstNonEmpty(comment.User, "anonymous")) + ` commented ` + html.EscapeString(relativeTime(parseTime(comment.At))) + `

    ` + html.EscapeString(comment.Body) + `

    `) + } + body.WriteString(`
    `) + if issue.Status == "closed" { + body.WriteString(``) + } else { + body.WriteString(``) + } + body.WriteString(`
    `) + s.renderPage(w, webPageTitle(s.title, "issue"), body.String()) +} + +func (s *webServer) handleSettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + info := s.settingsInfo(ctx) + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
    `) + body.WriteString(s.headerHTML(ref, "settings")) + body.WriteString(`
    `) + body.WriteString(`
    Repository settings
    `) + if strings.TrimSpace(s.cfg.brokerURL) == "" { + body.WriteString(`
    Settings are available for broker-backed repositories.
    `) + body.WriteString(`
    `) + s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) + return + } + body.WriteString(s.settingsAboutHTML(info)) + body.WriteString(s.settingsAccessHTML(info)) + body.WriteString(s.settingsBranchesHTML(info)) + body.WriteString(s.settingsPullRequestsHTML(info)) + body.WriteString(s.settingsDangerHTML(info)) + if len(info.Errors) > 0 { + body.WriteString(`

    Unavailable sections

    `) + for name, message := range info.Errors { + body.WriteString(`
    ` + html.EscapeString(name) + ` ` + html.EscapeString(message) + `
    `) + } + body.WriteString(`
    `) + } + body.WriteString(`
    `) + s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) +} + +func (s *webServer) settingsAboutHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

    About

    `) + b.WriteString(`
    `) + b.WriteString(``) + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(settingsMetaItem("Repository", logicalRepoDisplayName(firstNonEmpty(info.Repo.Logical, info.Title)))) + b.WriteString(settingsMetaItem("Provider", firstNonEmpty(info.Provider, info.Repo.Provider))) + b.WriteString(settingsMetaItem("Region", info.Region)) + b.WriteString(settingsMetaItem("Broker", strings.TrimPrefix(strings.TrimPrefix(info.BrokerURL, "https://"), "http://"))) + b.WriteString(`
    `) + return b.String() +} + +func (s *webServer) settingsAccessHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

    Access

    `) + b.WriteString(`
    `) + if len(info.Keys) == 0 { + b.WriteString(`
    No members found.
    `) + } else { + for _, key := range info.Keys { + fingerprint := publicKeyFingerprint(key.PublicKey) + if fingerprint == "" { + fingerprint = key.PublicKey + } + status := "active" + if key.Suspended { + status = "suspended" + } + b.WriteString(`
    `) + b.WriteString(`
    ` + html.EscapeString(firstNonEmpty(key.User, "unknown")) + `` + html.EscapeString(key.Role) + ` Ā· ` + html.EscapeString(status) + `` + html.EscapeString(fingerprint) + ``) + if key.Source != "" { + b.WriteString(`` + html.EscapeString(key.Source) + ``) + } + b.WriteString(`
    `) + if key.Role == "owner" { + b.WriteString(`Owner key`) + } else { + if key.Suspended { + b.WriteString(``) + } else { + b.WriteString(``) + } + b.WriteString(``) + } + b.WriteString(`
    `) + } + } + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`

    Invite member

    `) + b.WriteString(`
    `) + b.WriteString(`
    `) + return b.String() +} + +func (s *webServer) settingsBranchesHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

    Branches

    `) + if len(info.Protections) == 0 { + b.WriteString(`
    No protected branches.
    `) + } else { + for _, protection := range info.Protections { + mode := "PR required" + if protection.AllowOverrides { + mode += " Ā· owner/admin override" + } + b.WriteString(`
    ` + html.EscapeString(shortRefName(protection.Ref)) + `` + html.EscapeString(mode) + `
    `) + } + } + b.WriteString(`
    `) + b.WriteString(`

    Protect branch

    `) + b.WriteString(`
    `) + return b.String() +} + +func (s *webServer) settingsPullRequestsHTML(info webSettingsInfo) string { + return `

    Pull requests

    Protected branchesBranches can require pull requests before updates land.
    Review metadataApprovals, requested changes, comments, and inline review threads are stored by the broker.
    ` +} + +func (s *webServer) settingsDangerHTML(info webSettingsInfo) string { + logical := firstNonEmpty(info.Repo.Logical, info.Title) + var b strings.Builder + b.WriteString(`

    Danger Zone

    Owner-only repository actions live here. These actions can permanently change or delete repository state.
    `) + b.WriteString(`
    `) + b.WriteString(`

    Transfer ownership

    Creates a one-time accept command for the new owner. Their SSH signature becomes the new owner key.

    `) + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`

    Rename repository

    `) + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`

    Delete repository

    Deletes broker metadata, bucket contents, and the physical bucket.

    `) + b.WriteString(`
    `) + return b.String() +} + +func settingsMetaItem(label, value string) string { + if strings.TrimSpace(value) == "" { + value = "not configured" + } + return `
    ` + html.EscapeString(label) + `` + html.EscapeString(value) + `
    ` +} + +func publicKeyFingerprint(value string) string { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(value))) + if err != nil { + return "" + } + return ssh.FingerprintSHA256(pub) +} + func (s *webServer) handlePullRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, value string) { parts := strings.Split(strings.Trim(value, "/"), "/") if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { @@ -2130,12 +2718,20 @@ func (s *webServer) headerHTML(ref, repoPath string) string { codeActive := ` class="active"` commitsActive := "" prsActive := "" + issuesActive := "" + settingsActive := "" if repoPath == "commits" { codeActive = "" commitsActive = ` class="active"` } else if repoPath == "prs" { codeActive = "" prsActive = ` class="active"` + } else if repoPath == "settings" { + codeActive = "" + settingsActive = ` class="active"` + } else if repoPath == "issues" { + codeActive = "" + issuesActive = ` class="active"` } b.WriteString(`
    `) codeActions := "" if codeActive != "" { @@ -2158,10 +2756,29 @@ func (s *webServer) headerHTML(ref, repoPath string) string { b.WriteString(`
    ` + html.EscapeString(location) + `
    `) } b.WriteString(`
    `) + if banner := s.repoPolicyBannerHTML(); banner != "" { + b.WriteString(banner) + } b.WriteString(``) return b.String() } +func (s *webServer) repoPolicyBannerHTML() string { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return "" + } + var repoInfo brokerRepoInfoResponse + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/repo/info", brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)}, &repoInfo); err != nil { + return "" + } + if repoInfo.ReadOnly { + return `
    This repository has been set to read-only.
    ` + } + return "" +} + func (s *webServer) repoToolbarHTML(ref string, includeSearch bool) string { branchCount, tagCount := s.refCounts(context.Background()) var b strings.Builder @@ -2743,6 +3360,20 @@ func pullRequestListHTML(prs []brokerPullRequest) string { return b.String() } +func issueListHTML(issues []brokerIssue) string { + if len(issues) == 0 { + return `
    No issues found.
    ` + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + func prHeaderHTML(pr brokerPullRequest, active string) string { status := firstNonEmpty(pr.Status, "open") title := firstNonEmpty(pr.Title, "Untitled pull request") @@ -3792,6 +4423,16 @@ func relativeTime(ts int64) string { } } +func parseTime(value string) int64 { + if value == "" { + return 0 + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.Unix() + } + return 0 +} + func relativeTimeUnit(count int, unit, suffix string) string { if count < 1 { count = 1 From 154bb9693685aaa2749b680d9b9710acaca62b1a Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 14:08:17 +0200 Subject: [PATCH 04/17] 1.0.0-dev, broker architecture, test suite and web server --- .github/secret_scanning.yml | 4 + .github/workflows/build-artifacts.yml | 177 -- .../workflows/test-and-build-artifacts.yml | 346 +++ .github/workflows/test.yml | 66 + .gitignore | 4 + CHANGELOG.md | 40 + README.md | 594 ++-- .../bucketgit-serverless-architecture.png | Bin 0 -> 190159 bytes broker/aws/template.yaml | 115 +- broker/gcp/index.js | 117 +- broker/test_support/sqlite_broker.js | 435 +++ broker/testserver.js | 161 ++ broker_commands.go | 132 +- git_receive.go | 3 + main.go | 9 +- main_test.go | 56 + native_git.go | 2 +- s3_store.go | 17 +- ssh.go | 32 +- testsuite/README.md | 50 + testsuite/aws/admin_keys_invites.sh | 12 + testsuite/aws/admin_repo.sh | 12 + testsuite/aws/branch_protection.sh | 2 + testsuite/aws/danger_zone.sh | 2 + testsuite/aws/identity_selection.sh | 2 + testsuite/aws/init.sh | 10 + testsuite/aws/invites_ownership.sh | 2 + testsuite/aws/issues.sh | 15 + testsuite/aws/issues_permissions.sh | 2 + testsuite/aws/native_git_transport.sh | 2 + testsuite/aws/pr.sh | 17 + testsuite/aws/pr_depth.sh | 2 + testsuite/aws/public_private_access.sh | 2 + testsuite/aws/push_fetch_pull_lsremote.sh | 19 + testsuite/aws/roles_permissions.sh | 2 + testsuite/aws/ssh_key_types.sh | 31 + testsuite/aws/whoami_repos.sh | 9 + testsuite/gcp/admin_keys_invites.sh | 12 + testsuite/gcp/admin_repo.sh | 12 + testsuite/gcp/branch_protection.sh | 2 + testsuite/gcp/danger_zone.sh | 2 + testsuite/gcp/identity_selection.sh | 2 + testsuite/gcp/init.sh | 10 + testsuite/gcp/invites_ownership.sh | 2 + testsuite/gcp/issues.sh | 15 + testsuite/gcp/issues_permissions.sh | 2 + testsuite/gcp/native_git_transport.sh | 2 + testsuite/gcp/pr.sh | 17 + testsuite/gcp/pr_depth.sh | 2 + testsuite/gcp/public_private_access.sh | 2 + testsuite/gcp/push_fetch_pull_lsremote.sh | 19 + testsuite/gcp/roles_permissions.sh | 2 + testsuite/gcp/ssh_key_types.sh | 31 + testsuite/gcp/whoami_repos.sh | 9 + testsuite/lib/cases/branch_protection.sh | 63 + testsuite/lib/cases/danger_zone.sh | 32 + testsuite/lib/cases/identity_selection.sh | 62 + testsuite/lib/cases/invites_ownership.sh | 61 + testsuite/lib/cases/issues_permissions.sh | 37 + testsuite/lib/cases/local_more_porcelain.sh | 52 + testsuite/lib/cases/native_git_transport.sh | 45 + testsuite/lib/cases/pr_depth.sh | 46 + testsuite/lib/cases/public_private_access.sh | 34 + testsuite/lib/cases/roles_permissions.sh | 48 + testsuite/lib/testlib.sh | 189 ++ testsuite/local/add.sh | 11 + testsuite/local/branch_checkout_merge.sh | 11 + testsuite/local/commit.sh | 11 + testsuite/local/diff_log_show_status.sh | 14 + testsuite/local/more_porcelain.sh | 2 + testsuite/local/porcelain_misc.sh | 21 + testsuite/run-local-broker.sh | 94 + testsuite/run.sh | 47 + testsuite/sshkeys/admin | 7 + testsuite/sshkeys/admin.pub | 1 + testsuite/sshkeys/developer | 7 + testsuite/sshkeys/developer.pub | 1 + testsuite/sshkeys/ecdsa_owner | 9 + testsuite/sshkeys/ecdsa_owner.pub | 1 + testsuite/sshkeys/maintainer | 7 + testsuite/sshkeys/maintainer.pub | 1 + testsuite/sshkeys/outsider | 7 + testsuite/sshkeys/outsider.pub | 1 + testsuite/sshkeys/owner | 7 + testsuite/sshkeys/owner.pub | 1 + testsuite/sshkeys/read | 7 + testsuite/sshkeys/read.pub | 1 + testsuite/sshkeys/rsa_owner | 38 + testsuite/sshkeys/rsa_owner.pub | 1 + testsuite/sshkeys/triage | 7 + testsuite/sshkeys/triage.pub | 1 + web.go | 3 + www/app.css | 2398 +++++++++++++++++ www/app.js | 1953 ++++++++++++++ www/bgit-mark.png | Bin 0 -> 10749 bytes www/favicon.ico | Bin 0 -> 13812 bytes www/page.html | 24 + 97 files changed, 7409 insertions(+), 600 deletions(-) create mode 100644 .github/secret_scanning.yml delete mode 100644 .github/workflows/build-artifacts.yml create mode 100644 .github/workflows/test-and-build-artifacts.yml create mode 100644 .github/workflows/test.yml create mode 100644 architecture/bucketgit-serverless-architecture.png create mode 100644 broker/test_support/sqlite_broker.js create mode 100644 broker/testserver.js create mode 100644 testsuite/README.md create mode 100755 testsuite/aws/admin_keys_invites.sh create mode 100755 testsuite/aws/admin_repo.sh create mode 100644 testsuite/aws/branch_protection.sh create mode 100644 testsuite/aws/danger_zone.sh create mode 100644 testsuite/aws/identity_selection.sh create mode 100755 testsuite/aws/init.sh create mode 100644 testsuite/aws/invites_ownership.sh create mode 100755 testsuite/aws/issues.sh create mode 100644 testsuite/aws/issues_permissions.sh create mode 100644 testsuite/aws/native_git_transport.sh create mode 100755 testsuite/aws/pr.sh create mode 100644 testsuite/aws/pr_depth.sh create mode 100644 testsuite/aws/public_private_access.sh create mode 100755 testsuite/aws/push_fetch_pull_lsremote.sh create mode 100644 testsuite/aws/roles_permissions.sh create mode 100755 testsuite/aws/ssh_key_types.sh create mode 100755 testsuite/aws/whoami_repos.sh create mode 100755 testsuite/gcp/admin_keys_invites.sh create mode 100755 testsuite/gcp/admin_repo.sh create mode 100644 testsuite/gcp/branch_protection.sh create mode 100644 testsuite/gcp/danger_zone.sh create mode 100644 testsuite/gcp/identity_selection.sh create mode 100755 testsuite/gcp/init.sh create mode 100644 testsuite/gcp/invites_ownership.sh create mode 100755 testsuite/gcp/issues.sh create mode 100644 testsuite/gcp/issues_permissions.sh create mode 100644 testsuite/gcp/native_git_transport.sh create mode 100755 testsuite/gcp/pr.sh create mode 100644 testsuite/gcp/pr_depth.sh create mode 100644 testsuite/gcp/public_private_access.sh create mode 100755 testsuite/gcp/push_fetch_pull_lsremote.sh create mode 100644 testsuite/gcp/roles_permissions.sh create mode 100755 testsuite/gcp/ssh_key_types.sh create mode 100755 testsuite/gcp/whoami_repos.sh create mode 100644 testsuite/lib/cases/branch_protection.sh create mode 100644 testsuite/lib/cases/danger_zone.sh create mode 100644 testsuite/lib/cases/identity_selection.sh create mode 100644 testsuite/lib/cases/invites_ownership.sh create mode 100644 testsuite/lib/cases/issues_permissions.sh create mode 100644 testsuite/lib/cases/local_more_porcelain.sh create mode 100644 testsuite/lib/cases/native_git_transport.sh create mode 100644 testsuite/lib/cases/pr_depth.sh create mode 100644 testsuite/lib/cases/public_private_access.sh create mode 100644 testsuite/lib/cases/roles_permissions.sh create mode 100755 testsuite/lib/testlib.sh create mode 100755 testsuite/local/add.sh create mode 100755 testsuite/local/branch_checkout_merge.sh create mode 100755 testsuite/local/commit.sh create mode 100755 testsuite/local/diff_log_show_status.sh create mode 100644 testsuite/local/more_porcelain.sh create mode 100755 testsuite/local/porcelain_misc.sh create mode 100755 testsuite/run-local-broker.sh create mode 100755 testsuite/run.sh create mode 100644 testsuite/sshkeys/admin create mode 100644 testsuite/sshkeys/admin.pub create mode 100644 testsuite/sshkeys/developer create mode 100644 testsuite/sshkeys/developer.pub create mode 100644 testsuite/sshkeys/ecdsa_owner create mode 100644 testsuite/sshkeys/ecdsa_owner.pub create mode 100644 testsuite/sshkeys/maintainer create mode 100644 testsuite/sshkeys/maintainer.pub create mode 100644 testsuite/sshkeys/outsider create mode 100644 testsuite/sshkeys/outsider.pub create mode 100644 testsuite/sshkeys/owner create mode 100644 testsuite/sshkeys/owner.pub create mode 100644 testsuite/sshkeys/read create mode 100644 testsuite/sshkeys/read.pub create mode 100644 testsuite/sshkeys/rsa_owner create mode 100644 testsuite/sshkeys/rsa_owner.pub create mode 100644 testsuite/sshkeys/triage create mode 100644 testsuite/sshkeys/triage.pub create mode 100644 www/app.css create mode 100644 www/app.js create mode 100644 www/bgit-mark.png create mode 100644 www/favicon.ico create mode 100644 www/page.html diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000..55e10f6 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,4 @@ +# These are intentionally committed fixture keys for the local broker +# integration tests. They must never grant access to real infrastructure. +paths-ignore: + - "testsuite/sshkeys/**" diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml deleted file mode 100644 index be75fdb..0000000 --- a/.github/workflows/build-artifacts.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: Build Artifacts - -on: - push: - branches: - - main - - develop - paths: - - "*.go" - - "**/*.go" - - "go.mod" - - "go.sum" - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - name: ${{ matrix.artifact_name }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-14 - goos: darwin - goarch: arm64 - artifact_name: bgit-mac-arm64 - - os: macos-15-intel - goos: darwin - goarch: amd64 - artifact_name: bgit-mac-amd64 - - os: ubuntu-24.04 - goos: linux - goarch: amd64 - artifact_name: bgit-linux-amd64 - - os: ubuntu-24.04 - goos: linux - goarch: arm64 - artifact_name: bgit-linux-arm64 - - os: windows-2022 - goos: windows - goarch: amd64 - artifact_name: bgit-windows-amd64.exe - - os: windows-2022 - goos: windows - goarch: arm64 - artifact_name: bgit-windows-arm64.exe - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Resolve Version From Changelog - if: github.ref == 'refs/heads/main' - shell: pwsh - run: | - if (!(Test-Path "CHANGELOG.md")) { - throw "Missing CHANGELOG.md" - } - - $lines = Get-Content "CHANGELOG.md" - $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 - if (-not $line) { - throw "Could not find semantic version heading in CHANGELOG.md" - } - if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { - throw "Invalid semantic version heading in CHANGELOG.md" - } - - $version = $Matches[1] - $releaseTag = $version - $start = [Array]::IndexOf($lines, $line) - $notes = New-Object System.Collections.Generic.List[string] - - for ($i = $start + 1; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { - break - } - $notes.Add($lines[$i]) - } - - $releaseNotes = ($notes -join "`n").Trim() - if ([string]::IsNullOrWhiteSpace($releaseNotes)) { - throw "CHANGELOG.md section for $version has no release notes" - } - - $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 - - "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "ARTIFACT_FILE=${{ matrix.artifact_name }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Resolve Development Release - if: github.ref == 'refs/heads/develop' - shell: pwsh - run: | - $artifact = "${{ matrix.artifact_name }}" - if ($artifact.EndsWith(".exe")) { - $artifact = $artifact.Substring(0, $artifact.Length - 4) + "-dev.exe" - } else { - $artifact = "$artifact-dev" - } - - "Latest development release" | Out-File -FilePath "release-notes.md" -Encoding utf8 - - "APP_VERSION=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "ARTIFACT_FILE=$artifact" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Test - run: go test ./... - - - name: Build - shell: pwsh - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: "0" - run: | - New-Item -ItemType Directory -Force -Path "dist" | Out-Null - go build -trimpath -ldflags="-s -w -X main.version=${{ env.APP_VERSION }}" -o "dist/${{ env.ARTIFACT_FILE }}" . - - if (!(Test-Path "dist/${{ env.ARTIFACT_FILE }}")) { - throw "Missing expected artifact: dist/${{ env.ARTIFACT_FILE }}" - } - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ env.ARTIFACT_FILE }} - path: dist/${{ env.ARTIFACT_FILE }} - if-no-files-found: error - - - name: Publish To Release - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $tag = $env:RELEASE_TAG - $name = $env:RELEASE_NAME - $asset = "dist/${{ env.ARTIFACT_FILE }}" - $repo = "${{ github.repository }}" - $target = "${{ github.ref_name }}" - - if ($tag -eq "dev-latest") { - git tag -f $tag "${{ github.sha }}" - git push origin "refs/tags/$tag" --force - } - - gh release view $tag --repo $repo *> $null - if ($LASTEXITCODE -ne 0) { - if ($tag -eq "dev-latest") { - gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md --prerelease - } else { - gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md - } - if ($LASTEXITCODE -ne 0) { - gh release view $tag --repo $repo *> $null - if ($LASTEXITCODE -ne 0) { - throw "Could not create or access release $tag" - } - } - } - - gh release edit $tag --repo $repo --title $name --notes-file release-notes.md - gh release upload $tag $asset --repo $repo --clobber diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml new file mode 100644 index 0000000..773ee44 --- /dev/null +++ b/.github/workflows/test-and-build-artifacts.yml @@ -0,0 +1,346 @@ +name: Test And Build Artifacts + +on: + push: + branches: + - main + - develop + paths: + - "*.go" + - "**/*.go" + - "go.mod" + - "go.sum" + - "CHANGELOG.md" + - "broker/**" + - "www/**" + - "testsuite/**" + - ".github/workflows/test-and-build-artifacts.yml" + workflow_dispatch: + +permissions: + contents: write + +jobs: + unit: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Unit Tests + run: go test ./... + + build: + name: Build Multiarchitecture Artifacts + needs: + - unit + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Resolve Version From Changelog + if: github.ref == 'refs/heads/main' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = $Matches[1] + $releaseTag = $version + $start = [Array]::IndexOf($lines, $line) + $notes = New-Object System.Collections.Generic.List[string] + + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { + break + } + $notes.Add($lines[$i]) + } + + $releaseNotes = ($notes -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($releaseNotes)) { + throw "CHANGELOG.md section for $version has no release notes" + } + + $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Development Release + if: github.ref == 'refs/heads/develop' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = "$($Matches[1])-dev" + + "Latest development release for $version" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Manual Development Release + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' + shell: pwsh + run: | + "Manual development build" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "dist" | Out-Null + + $targets = @( + @{ GOOS = "darwin"; GOARCH = "arm64"; Artifact = "bgit-mac-arm64" }, + @{ GOOS = "darwin"; GOARCH = "amd64"; Artifact = "bgit-mac-amd64" }, + @{ GOOS = "linux"; GOARCH = "amd64"; Artifact = "bgit-linux-amd64" }, + @{ GOOS = "linux"; GOARCH = "arm64"; Artifact = "bgit-linux-arm64" }, + @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" }, + @{ GOOS = "windows"; GOARCH = "arm64"; Artifact = "bgit-windows-arm64.exe" } + ) + + foreach ($target in $targets) { + $artifact = $target.Artifact + if ($env:RELEASE_TAG -eq "dev-latest") { + if ($artifact.EndsWith(".exe")) { + $artifact = $artifact.Substring(0, $artifact.Length - 4) + "-dev.exe" + } else { + $artifact = "$artifact-dev" + } + } + + $env:GOOS = $target.GOOS + $env:GOARCH = $target.GOARCH + $env:CGO_ENABLED = "0" + + go build -trimpath -ldflags="-s -w -X main.version=$env:APP_VERSION" -o "dist/$artifact" . + + if (!(Test-Path "dist/$artifact")) { + throw "Missing expected artifact: dist/$artifact" + } + } + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: bgit-artifacts + path: dist/* + if-no-files-found: error + + integration: + name: Integration Tests (${{ matrix.os }}) + needs: + - build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + binary: bgit-mac-arm64 + - os: macos-15-intel + binary: bgit-mac-amd64 + - os: ubuntu-24.04 + binary: bgit-linux-amd64 + - os: windows-2022 + binary: bgit-windows-amd64.exe + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 25 + + - name: Download Built Artifacts + uses: actions/download-artifact@v4 + with: + name: bgit-artifacts + path: dist + + - name: Prepare Built Binary + shell: bash + run: | + if [[ "${{ matrix.binary }}" == *.exe ]]; then + cp "dist/${{ matrix.binary }}" ./bgit.exe + chmod +x ./bgit.exe + printf 'BGIT_PATH=%s/bgit.exe\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" + ./bgit.exe --version + else + cp "dist/${{ matrix.binary }}" ./bgit + chmod +x ./bgit + printf 'BGIT_PATH=%s/bgit\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" + ./bgit --version + fi + + - name: Local Broker Integration (GCP runtime) + shell: bash + env: + BGIT_TEST_USE_EXISTING_BINARY: "1" + run: BGIT="$BGIT_PATH" ./testsuite/run-local-broker.sh gcp + + - name: Local Broker Integration (AWS runtime) + shell: bash + env: + BGIT_TEST_USE_EXISTING_BINARY: "1" + run: BGIT="$BGIT_PATH" ./testsuite/run-local-broker.sh aws + + publish: + name: Publish Release + needs: + - integration + runs-on: ubuntu-24.04 + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Built Artifacts + uses: actions/download-artifact@v4 + with: + name: bgit-artifacts + path: dist + + - name: Resolve Version From Changelog + if: github.ref == 'refs/heads/main' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = $Matches[1] + $releaseTag = $version + $start = [Array]::IndexOf($lines, $line) + $notes = New-Object System.Collections.Generic.List[string] + + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { + break + } + $notes.Add($lines[$i]) + } + + $releaseNotes = ($notes -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($releaseNotes)) { + throw "CHANGELOG.md section for $version has no release notes" + } + + $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Development Release + if: github.ref == 'refs/heads/develop' + shell: pwsh + run: | + "Latest development release" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Publish To Release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = $env:RELEASE_TAG + $name = $env:RELEASE_NAME + $repo = "${{ github.repository }}" + $target = "${{ github.ref_name }}" + + if ($tag -eq "dev-latest") { + git tag -f $tag "${{ github.sha }}" + git push origin "refs/tags/$tag" --force + } + + gh release view $tag --repo $repo *> $null + if ($LASTEXITCODE -ne 0) { + if ($tag -eq "dev-latest") { + gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md --prerelease + } else { + gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md + } + if ($LASTEXITCODE -ne 0) { + gh release view $tag --repo $repo *> $null + if ($LASTEXITCODE -ne 0) { + throw "Could not create or access release $tag" + } + } + } + + gh release edit $tag --repo $repo --title $name --notes-file release-notes.md + Get-ChildItem -Path dist -File | ForEach-Object { + gh release upload $tag $_.FullName --repo $repo --clobber + if ($LASTEXITCODE -ne 0) { + throw "Could not upload release asset $($_.FullName)" + } + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..309f1e3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,66 @@ +name: Test + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Unit Tests + run: go test ./... + + local-broker-integration: + name: Local Broker Integration (${{ matrix.runtime }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + runtime: + - gcp + - aws + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 25 + + - name: Local Broker Integration + shell: bash + run: ./testsuite/run-local-broker.sh "${{ matrix.runtime }}" diff --git a/.gitignore b/.gitignore index b0f2549..02fc5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ bgit coverage.txt .DS_Store attic/ +.broker-test/ +testsuite/local/repo/ +testsuite/gcp/repo/ +testsuite/aws/repo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d72fdd0..2854191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to `bgit` are documented in this file. This project follows semantic versioning. +## 1.0.0 + +Breaking changes + +- BucketGit is now broker-first. Normal repository operations go through a + broker-backed repo model by default; legacy direct bucket and cloud IAM flows + moved under `bgit direct`. +- `bgit admin` now manages broker-backed repository users, keys, protection, + issues, visibility, and danger-zone repository controls instead of cloud IAM. +- Repository setup and selection now use broker profiles from + `~/.bgit/config.yaml`, including region-qualified profiles. + +Added + +- Broker-first setup and repository initialization, including cloud profile + discovery, owner SSH key import, multi-region broker provisioning, and + `~/.bgit/config.yaml`. +- Broker-issued object-transfer capabilities, logical repo mapping, roles, + branch protection, pull requests, issues, and GitHub SSH key import. +- Repository visibility, read-only mode, logical rename, destructive owner-only + delete controls, owner transfer, member invites, and repo-scoped invite + cancellation. +- `bgit web` as a broker-aware repository browser with embedded assets, + pull-request review flows, issues, settings, capability-aware controls, and + local/remote state indicators. +- `bgit direct` as the explicit low-level object-storage and cloud IAM recovery + path. +- Local broker integration test mode for GCP and AWS runtimes, with coverage for + roles, branch protection, PRs, issues, native Git transport, public/private + access, identity selection, and danger-zone controls. + +Changed + +- Push/fetch/read paths use the broker by default, with region-qualified + profiles and `--profile NAME --region REGION` disambiguation. +- Setup is more guided for GCP/AWS onboarding, project/billing/API checks, and + interactive profile, region, and SSH key selection. +- BucketGit identity is configurable globally or per repo, with a clear prompt + before pushing with the default client identity. + ## 0.4.0 Added diff --git a/README.md b/README.md index 69c018b..2a87ffb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # bgit -`bgit` is a Git CLI for repositories stored directly in object storage. It -keeps a normal `.git` checkout on disk, so developers can use familiar Git -commands locally, while `bgit` syncs Git objects, branches, and tags to a -`gs://` or `s3://` repository. +`bgit` is a Git CLI for repositories stored directly in cloud buckets. It keeps +normal `.git` checkouts on disk, so developers can use familiar local Git +workflows, while BucketGit stores repository objects and refs in GCS or S3 and +coordinates access through a lightweight broker. -Use it when you want a lightweight Git backend in GCS or S3 without running a +Use it when you want Git repositories in cloud object storage without running a Git server. ## Project @@ -37,501 +37,358 @@ Check the installed version: bgit --version ``` -## Build +## How BucketGit Works -```bash -go build -o bgit . -``` - -## Features - -- Clone, initialize, fetch, pull, and push repositories backed by GCS or S3. -- Store a repository at any `gs://bucket/path/to/repo.git` or - `s3://bucket/path/to/repo.git` prefix. -- Work in a normal Git checkout with a standard `.git` directory. -- Use native local workflows for status, add, commit, checkout, branch, merge, - tag, diff, log, show, reset, restore, stash, revert, grep, blame, - cherry-pick, clean, describe, ls-files, ls-tree, archive, config, rev-parse, - rm, and mv. -- Push branches and tags back to object storage with `bgit push`. -- Configure an origin with `bgit origin` or `bgit remote add origin`. -- Grant read, write, admin, public, or private bucket access with `bgit admin`. -- Create and save gcloud profiles with `bgit create-gcloud-profile`. -- Configure native Git fetch/push over SSH with `bgit ssh setup` and the - serverless broker. -- Browse remote or local repositories with `bgit web`. -- Create the target GCS or S3 bucket automatically when permissions allow it. -- Run direct bucket inspection commands for scripts and automation. +BucketGit has two layers: -## Requirements +- A normal local Git checkout on your machine. +- A broker-backed repository stored directly in GCS or S3. -- Go 1.22 or newer to build from source. -- The `git` executable available on `PATH` for repository initialization, - checkout setup, and compatibility config/remote metadata. -- Google Cloud Storage access through `gcloud` or Application Default - Credentials for `gs://` repositories. -- AWS credentials through the AWS SDK credential chain for `s3://` - repositories. +The broker handles repository mapping, roles, SSH-key authorization, pull +requests, issues, branch protection, and short-lived object-transfer +capabilities. Developers do not need long-lived bucket credentials for everyday +clone, fetch, pull, push, review, or web browsing flows. -By default, `bgit` asks `gcloud` for an OAuth access token and uses that token -for GCS API calls: +Direct bucket access still exists under `bgit direct` for recovery, migration, +and low-level inspection. It is not the normal user workflow. -```bash -gcloud auth login -gcloud auth print-access-token -``` +## Quickstart -This follows the active gcloud configuration. To use a named gcloud profile: +Set up BucketGit for one or more cloud profiles: ```bash -bgit --profile test-profile clone gs://my-bucket/repositories/demo.git -bgit --profile test-profile push +bgit setup ``` -Internally, bgit runs `gcloud auth print-access-token`. When a profile is set, -bgit runs that subprocess with `CLOUDSDK_ACTIVE_CONFIG_NAME` set to the profile -name so it matches gcloud's named configuration behavior. -Global flags such as `--profile` and `--auth` can be placed before or after the -command. - -### Gcloud Profiles +`bgit setup` discovers GCP and AWS profiles, lets you choose regions, imports +owner SSH keys, deploys or updates the broker, and writes global configuration to +`~/.bgit/config.yaml`. -Use an existing gcloud configuration for one command: +Create a new repository: ```bash -bgit push --profile test-profile -``` - -You can also save auth defaults in the checkout: +mkdir demo +cd demo +bgit init -```bash -bgit config bucketgit.auth gcloud -bgit config bucketgit.profile test-profile +echo "hello" > README.md +bgit add README.md +bgit commit -m "Initial commit" +bgit push ``` -Check the saved profile: +Clone an existing broker-backed repository: ```bash -bgit config bucketgit.profile +bgit clone https://broker.example.com/team/demo.git ./demo ``` -Use `bucketgit.auth adc` to make that checkout use ADC by default. If no auth -config is set, bgit defaults to `gcloud`; if no profile/configuration is set, -bgit uses the active gcloud configuration. - -To create a new gcloud profile and save it in the current checkout: +Inside an initialized checkout, normal Git commands also work for fetch and push +through the `core.sshCommand` written by `bgit init`: ```bash -bgit create-gcloud-profile my-profile +git fetch +git push ``` -This runs `gcloud config configurations create my-profile`, then -`gcloud auth login --configuration my-profile`. Use `--yes` to skip bgit's -confirmation prompt. The gcloud browser login still runs. +## Common Commands ```bash -bgit create-gcloud-profile --yes my-profile -``` +bgit setup +bgit setup profile create --provider gcp work +bgit setup profile create --provider aws work -For CI, service accounts, or environments where ADC is preferred, opt in -explicitly: +bgit init +bgit init --noninteractive --repo team/demo --profile work.europe-west1 +bgit clone https://broker.example.com/team/demo.git ./demo +bgit web -```bash -bgit --auth adc push -``` +bgit status +bgit add -A +bgit commit -m "Update" +bgit checkout -b feature/docs +bgit diff +bgit log --oneline -When `bgit put` or `bgit --bucket ... init` targets a GCS bucket that does not -exist, `bgit` attempts to create it in the active Google Cloud project. The -project is read from `GOOGLE_CLOUD_PROJECT`, `GCLOUD_PROJECT`, `GCP_PROJECT`, -or `gcloud config get-value project` using the selected configuration. The -environment variables take precedence, which is useful when a gcloud profile has -an account but no project set. +bgit fetch +bgit pull +bgit push +bgit push --tags +bgit push --delete feature/docs +bgit ls-remote -For S3 repositories, `bgit push` creates the bucket when it does not exist and -the selected AWS credentials have permission. Region selection follows -`AWS_REGION`, then `AWS_DEFAULT_REGION`, then `us-east-1`. +bgit pr create --title "Add docs" --source feature/docs --target main +bgit pr list +bgit pr view 1 +bgit pr diff 1 +bgit pr merge 1 -If Google returns an auth error, first check that the selected gcloud -configuration has the expected account and project: +bgit issue create "Bug report" --body "Details" +bgit issue list +bgit issue view 1 -```bash -gcloud config configurations list -CLOUDSDK_ACTIVE_CONFIG_NAME=test-profile gcloud auth print-access-token -CLOUDSDK_ACTIVE_CONFIG_NAME=test-profile gcloud config get-value project +bgit whoami +bgit repos mine ``` -### AWS Profiles +## Setup And Profiles -For `s3://` origins, bgit uses the AWS SDK credential chain. It supports -`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, temporary credentials through -`AWS_SESSION_TOKEN`, IAM roles, SSO-backed profiles, and the credentials/config -files written by the AWS CLI. +Global configuration is stored in `~/.bgit/config.yaml`. Profiles are +provider- and region-aware, so the same cloud account can have brokers in +multiple regions. -Region selection follows `AWS_REGION`, then `AWS_DEFAULT_REGION`, and defaults -to `us-east-1` when neither is set. - -Use an AWS CLI profile for one command: +Examples: ```bash -bgit clone s3://my-bucket/repositories/demo.git --profile work -bgit push --profile work +bgit init --noninteractive --repo app --profile work.europe-west1 +bgit push --profile work --region europe-west1 ``` -Save the profile in a checkout: +If a profile has multiple configured regions, pass the region explicitly: ```bash -bgit config bucketgit.profile work +bgit push --profile work --region eu-west-1 ``` -## Quickstart - -Clone an existing object-storage-backed repository: +or use a region-qualified profile name: ```bash -bgit clone gs://my-bucket/repositories/demo.git ./demo -bgit clone s3://my-bucket/repositories/demo.git ./demo-s3 --profile work -cd demo - -git status -git log --oneline +bgit push --profile work.eu-west-1 ``` -For read-only remote operations such as `clone`, `fetch`, `pull`, and -`ls-remote`, `bgit` first tries an anonymous public read. If the repository is -private, it automatically retries with the configured GCS or AWS credentials. - -Make a change and push it: +`bgit setup` can also create cloud CLI profiles: ```bash -echo "hello" > README.md -bgit add README.md -bgit commit -m "Add README" -bgit push +bgit setup profile create --provider gcp work +bgit setup profile create --provider aws work ``` -Create a new repository from an existing directory: - -```bash -mkdir demo -cd demo - -bgit init -echo "hello" > README.md -bgit add README.md -bgit commit -m "Initial commit" +GCP setup uses `gcloud` configurations. AWS setup reads AWS config/credentials +files and can use the AWS CLI when profile creation is requested. -bgit origin gs://my-bucket/repositories/demo.git -# or: -bgit origin s3://my-bucket/repositories/demo.git -bgit push -``` +## Identity -## Web UI +BucketGit supports a global name and email in `~/.bgit/config.yaml` and per-repo +identity in `.git/config`, matching the way Git users expect identity to work. +The repo-local identity overrides the global one. -`bgit web` serves a small local repository browser on `127.0.0.1:8042`: +If no identity is configured, BucketGit falls back to a default client identity +and warns before pushing. -```bash -bgit web -``` +## Access Control -By default it serves the configured remote repository using the same read path -as `bgit fetch` and `bgit ls-remote`: anonymous public read first, then -authenticated GCS/S3 retry when the repository is private. It also honors -`bucketgit.profile` and `--profile`. +Repository access is broker-backed and SSH-key based. Roles are: -If the checkout is configured with `bucketgit.broker`, `bgit web` can fall back -to broker-mediated read access signed by the user's ssh-agent key. This lets a -user who only has SSH broker access browse the repository without direct cloud -credentials. +- `owner` +- `admin` +- `maintainer` +- `developer` +- `triage` +- `read` -The web UI includes a branch/tag selector, clone command copy buttons, file and -raw blob views, commit author and committer metadata, and per-commit diffs. +Owners cannot be deleted or suspended. Ownership transfer uses a two-step flow: +the current owner creates a transfer command, and the new owner accepts it with +an SSH signature. -Use `--local` to browse the local `.git` object store instead: +Useful admin commands: ```bash -bgit web --local -bgit web --port 9000 -``` +bgit admin keys list +bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub +bgit admin keys import-github octocat --role triage +bgit admin keys suspend KEY_OR_FINGERPRINT +bgit admin keys remove KEY_OR_FINGERPRINT -## Git SSH Transport +bgit admin invite-user --broker https://broker.example.com --user ada --role developer team/demo.git +bgit admin accept-invite CODE +bgit admin cancel-invite --broker https://broker.example.com --user ada team/demo.git -`bgit ssh setup` configures a checkout so normal Git clients use `bgit` as the -SSH transport: +bgit admin confirm-ownership-transfer --broker https://broker.example.com team/demo.git +bgit admin accept-ownership-transfer CODE +bgit admin cancel-ownership-transfer --broker https://broker.example.com team/demo.git -```bash -bgit ssh setup gs://my-bucket/repositories/demo.git -bgit ssh setup s3://my-bucket/repositories/demo.git --profile work +bgit admin protect add main +bgit admin protect list +bgit admin protect remove main ``` -This writes a Git remote like `git@git.bucketgit.com:bucket/prefix.git` and -sets `core.sshCommand=bgit ssh`. Fresh native Git clones can use the same URL -when `GIT_SSH_COMMAND` points at `bgit ssh`: +A repo can have at most one active pending invite per username. Invite +cancellation is repo-scoped. + +## Repository Settings + +Broker-backed repositories support public/private visibility, read-only mode, +issues, branch protection, logical rename, and owner-only destructive delete. ```bash -GIT_SSH_COMMAND="bgit ssh" git clone git@git.bucketgit.com:my-bucket/repositories/demo.git +bgit admin repo visibility public +bgit admin repo visibility private +bgit admin repo readonly on +bgit admin repo readonly off +bgit admin repo issues on +bgit admin repo issues off +bgit admin repo rename new-name +bgit admin repo delete --yes ``` -SSH Git operations are authorized through the bgit broker. Fetch, clone, and -`ls-remote` require an active key with `read`, `write`, or `admin`. Push -requires `write` or `admin`. Suspended keys are rejected. +Public repositories can be cloned and browsed without an SSH key. Private +repositories require a recognized broker SSH key. -When a broker is configured, both native `git push` through `bgit ssh` and -`bgit push` use the broker for compare-and-swap ref updates before mirroring refs -back to the bucket. This gives AWS and GCP the same concurrent-push behavior: -one writer wins and stale writers are rejected instead of silently overwriting a -ref. +## Pull Requests And Issues -Direct `bgit` commands against `gs://` or `s3://` origins still use the selected -cloud credentials. If the broker is unavailable and an operator needs to bypass -broker coordination, use: +Pull requests and issues are broker metadata, not part of the Git protocol. +BucketGit implements them on top of repository refs and broker-side metadata. ```bash -bgit push --skip-broker -``` +bgit pr create --title "Add docs" --source feature/docs --target main +bgit pr list +bgit pr view 1 +bgit pr diff 1 +bgit pr comment 1 "Looks good" +bgit pr approve 1 "Approved" +bgit pr reject 1 "Please change this" +bgit pr merge 1 --delete-branch +bgit pr close 1 -When no broker is configured, `bgit push` writes refs directly to the bucket and -accepts the usual last-writer-wins risk. +bgit issue create "Missing docs" --body "The setup page needs examples." +bgit issue list +bgit issue comment 1 "I can take this." +bgit issue close 1 +bgit issue reopen 1 +``` -For GCP broker bootstrap, `bgit ssh setup` enables the required APIs and uses a -named Firestore database called `bgit`. If that database does not exist yet, the -caller needs `datastore.databases.create`, for example via -`roles/datastore.owner`. This permission is only needed while creating the -database; later repo/key administration uses the deployed broker and SSH admin -keys. +Branch protection is enforced by the broker. Protected branches can require the +PR merge path, with optional owner/admin override. -Broker-mediated `bgit web` reads use the broker runtime's cloud permissions to -read repository objects. The generated AWS broker role includes S3 read/list -permissions. On GCP, grant the Cloud Run function service account storage -read/list access if the repository bucket is outside the function's default -project permissions. +## Web UI -The broker tracks repositories and SSH keys: +`bgit web` serves a local browser UI on `127.0.0.1:8042`: ```bash -bgit ssh repo add -bgit ssh keys list -bgit ssh keys add --user ada --role read --key ~/.ssh/ada.pub -bgit ssh keys suspend KEY_OR_COMMENT -bgit ssh keys remove KEY_OR_COMMENT +bgit web ``` -## Repository URLs +The web UI uses the configured repository and broker by default. It shows files, +commits, pull requests, issues, repository settings, capability-aware controls, +local dirty/staged/unpushed state, and remote sync status. -Repository URLs use the `gs://` or `s3://` scheme: +Use local-only mode to browse the local `.git` object store without broker +refreshes: -```text -gs://bucket-name/path/to/repo.git -s3://bucket-name/path/to/repo.git +```bash +bgit web --local +bgit web --port 9000 ``` -The bucket is the object-storage bucket name. Everything after the bucket is the -repository prefix. For example, this repository: +The web assets are embedded into the `bgit` binary at build time. -```text -gs://my-bucket/repositories/demo.git -``` +## Native Git Transport -is stored under: +`bgit init` writes a Git remote like: ```text -gs://my-bucket/repositories/demo.git/HEAD -gs://my-bucket/repositories/demo.git/objects/... -gs://my-bucket/repositories/demo.git/refs/... +git@git.bucketgit.com:team/demo.git ``` -The same layout is used for S3: +and configures: ```text -s3://my-bucket/repositories/demo.git/HEAD -s3://my-bucket/repositories/demo.git/objects/... -s3://my-bucket/repositories/demo.git/refs/... +core.sshCommand=bgit ssh ``` -## Common Commands +That lets native Git use BucketGit for fetch and push inside initialized +repositories: ```bash -bgit --version -bgit clone gs://my-bucket/repositories/demo.git [directory] -bgit clone s3://my-bucket/repositories/demo.git [directory] -bgit init [directory] -bgit origin gs://my-bucket/repositories/demo.git -bgit origin s3://my-bucket/repositories/demo.git -bgit ssh setup gs://my-bucket/repositories/demo.git -bgit web - -bgit fetch -bgit pull -bgit push -bgit push --skip-broker -bgit push --tags -bgit push --delete feature -bgit ls-remote -bgit admin grant-write user:dev@example.com - -bgit checkout -b feature -bgit checkout main -bgit branch -bgit merge feature -bgit tag v1.0.0 - -bgit status -bgit add -A -bgit commit -m "Update" -bgit diff -bgit log --oneline -bgit show HEAD -bgit restore README.md -bgit reset --hard HEAD -bgit stash -bgit revert HEAD -bgit config user.name "Ada Lovelace" -bgit rev-parse HEAD +git fetch +git push ``` -Local workflow commands are implemented by `bgit` for the supported subset. -Commands outside that subset return `Unsupported` instead of delegating to the -system `git` binary. +Native Git transport is authorized through the broker. Ref updates use +compare-and-swap checks so stale writers are rejected instead of silently +overwriting refs. -## Origins +## Direct Bucket Mode -`bgit clone` writes the origin into `.git/config` automatically. To attach an -origin to an existing checkout, run: +Direct bucket mode is the low-level escape hatch for recovery, migration, +scripts, and debugging. It uses cloud credentials directly and bypasses the +normal broker-first workflow. ```bash -bgit origin gs://my-bucket/repositories/demo.git -bgit origin s3://my-bucket/repositories/demo.git +bgit direct help +bgit direct clone gs://bucket/repositories/demo.git +bgit direct clone s3://bucket/repositories/demo.git +bgit direct fetch +bgit direct push +bgit --bucket my-bucket --prefix repositories/demo.git direct ls docs/ +bgit --bucket my-bucket --prefix repositories/demo.git direct cat docs/readme.md ``` -You can also use Git-style remote commands: +Cloud IAM and bucket-policy recovery commands also live under direct mode: ```bash -bgit remote add origin gs://my-bucket/repositories/demo.git -bgit remote add origin s3://my-bucket/repositories/demo.git -bgit remote set-url origin gs://my-bucket/repositories/demo.git +bgit direct admin grant-read user:dev@example.com +bgit direct admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com +bgit direct admin grant-admin arn:aws:iam::123456789012:role/Admin ``` -If `bgit push` is run without an origin, it prints a copy-pasteable example: +## Broker Maintenance -```text -No configured push destination. -Either specify the repository from the command-line: - - bgit --bucket bucket-name --prefix path/to/repo.git push - -or configure a bgit origin: - - bgit origin gs://bucket-name/path/to/repo.git - bgit origin s3://bucket-name/path/to/repo.git - -and then push: - - bgit push -``` - -## Access Control - -`bgit admin` grants bucket access using the selected cloud profile. Run it -inside a checkout to infer the bucket and prefix from `.git/config`, or pass -`--bucket` explicitly. - -For GCS repositories: +Broker maintenance commands are intentionally separated from normal user flows: ```bash -bgit admin grant-read user:dev@example.com -bgit admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com -bgit admin --bucket my-bucket grant-admin admin@example.com -bgit admin make-public -bgit admin make-private +bgit janitor members reindex +bgit broker delete --provider gcp --profile work --region europe-west1 --yes +bgit broker delete --provider aws --profile work --region eu-west-1 --yes ``` -GCS `grant-read` grants `roles/storage.objectViewer` and -`roles/storage.legacyBucketReader`. `grant-write` grants -`roles/storage.objectAdmin` and `roles/storage.legacyBucketReader`. -`grant-admin` grants `roles/storage.admin`. `make-public` grants anonymous read -access at bucket level. `make-private` removes `allUsers` and -`allAuthenticatedUsers` from bgit's bucket-level read roles. +Use them for repair, test cleanup, or broker decommissioning. -The caller must already have permission to read and update the bucket IAM -policy, such as `roles/storage.admin` on the bucket. +## Development And Tests -For S3 repositories: +Build from source: ```bash -bgit admin grant-read arn:aws:iam::123456789012:role/Developer -bgit admin --bucket s3://my-bucket/repositories/demo.git grant-write 123456789012 -bgit admin --bucket s3://my-bucket/repositories/demo.git grant-admin arn:aws:iam::123456789012:role/Admin -bgit admin --bucket s3://my-bucket/repositories/demo.git make-public -bgit admin --bucket s3://my-bucket/repositories/demo.git make-private +go build -o bgit . ``` -S3 identities must be IAM or STS ARNs, 12 digit AWS account IDs, or `*`. -`grant-read` grants `s3:ListBucket` for the repository prefix and -`s3:GetObject` for objects under that prefix. `grant-write` adds -`s3:PutObject`, `s3:DeleteObject`, and multipart abort access. `grant-admin` -grants `s3:*` for the bucket and repository prefix. The caller must already have -permission to read and update the bucket policy. - -S3 `make-public` removes bucket-level Block Public Access and adds anonymous -read access for the repository prefix. `make-private` removes bgit's anonymous -statements for that prefix and restores bucket-level Block Public Access. - -## Branches And Tags - -New repositories default to the `main` branch. Use `--branch` when cloning or -using direct GCS mode to target another branch: +Run unit tests: ```bash -bgit --branch develop clone gs://my-bucket/repositories/demo.git -bgit --branch release fetch +go test ./... ``` -Tags are regular Git tags in the object-storage-backed repository: +Run the local broker integration suite: ```bash -bgit tag v1.0.0 -bgit tag -a v1.0.1 -m "Release v1.0.1" -bgit push --tags -bgit ls-remote --tags +./testsuite/run.sh +BGIT_TEST_PROVIDER=gcp ./testsuite/run.sh +BGIT_TEST_PROVIDER=aws ./testsuite/run.sh ``` -## Direct Bucket Mode - -Most developers should use `clone`, `init`, `origin`, and `push`. Direct bucket -mode is available for scripts and one-off inspection without a checkout: - -```bash -bgit --bucket my-bucket --prefix repositories/demo.git ls docs/ -bgit --bucket my-bucket --prefix repositories/demo.git cat docs/readme.md -bgit --bucket my-bucket --prefix repositories/demo.git log --limit 10 -bgit --bucket my-bucket --prefix repositories/demo.git put docs/readme.md --file README.md -m "Add readme" --author "Ada Lovelace" --email ada@example.com -``` +The integration suite uses local SQLite-backed broker runtimes and does not +require cloud credentials or deployed brokers. -## How It Works +## Requirements -`bgit` stores Git objects and refs in an object-storage prefix using the normal Git -repository layout. Remote operations read and write those objects and refs -directly through the GCS or S3 API. +Runtime requirements depend on the command: -Local checkouts remain normal Git worktrees. `bgit` implements the supported -local workflow commands directly, uses the `git` executable only for repository -setup/config compatibility, and uses object-storage-backed remote updates for -collaboration. +- `git` on `PATH` for repository initialization and native Git compatibility. +- `ssh-agent`/`ssh-add` for broker SSH-key signing flows. +- `gcloud` for GCP setup and profile creation. +- AWS config/credentials files and optionally the AWS CLI for AWS setup/profile + creation. +- Go 1.22 or newer to build from source. ## Unsupported Commands -Some Git commands depend on Git's network protocol, server-side hooks, packfile -maintenance, or repository features that `bgit` does not emulate. Unsupported -commands return: +BucketGit implements the supported local workflow commands directly. Commands +outside that subset return an unsupported-command error instead of delegating to +the system Git binary. -```text -Unsupported: '' is not supported by bgit -``` - -Unsupported commands include `rebase`, `daemon`, `submodule`, `lfs`, `gc`, -`fsck`, `repack`, `prune`, `worktree`, credential helpers, server helpers, and -related maintenance commands. Native Git fetch and push are supported inside -repositories configured with `bgit ssh setup`. +Unsupported commands include repository maintenance and server features such as +`daemon`, `submodule`, `lfs`, `gc`, `fsck`, `repack`, `prune`, `worktree`, +credential helpers, and related server helpers. ## Contributing @@ -547,14 +404,3 @@ fork-to-pull-request workflow and the checks to run before opening a PR. `bgit` is provided as-is, without warranty of any kind. You are responsible for testing it against your own repositories, access controls, backup strategy, and operational requirements before relying on it in production. - -## Help - -```bash -bgit help -bgit help push -bgit push --help -bgit --help push -bgit push help -bgit --version -``` diff --git a/architecture/bucketgit-serverless-architecture.png b/architecture/bucketgit-serverless-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..a1861a56c3b432e2241fd82b7e96ec3387b695dd GIT binary patch literal 190159 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 UHU%jgfd9bBNGOU|i0TLYA9usU_W%F@ literal 0 HcmV?d00001 diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index 26b211c..5afee22 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -273,8 +273,10 @@ Resources: entry.data.audit = (entry.data.audit || []).concat([{...event, at: new Date().toISOString()}]).slice(-500); } function readSSHString(buf, offset) { + if (offset + 4 > buf.length) throw new Error("invalid SSH wire string"); const len = buf.readUInt32BE(offset); const start = offset + 4; + if (start + len > buf.length) throw new Error("invalid SSH wire string"); return {value: buf.subarray(start, start + len), offset: start + len}; } function header(event, name) { @@ -288,15 +290,82 @@ Resources: function normalizeKey(key) { return String(key || "").trim().split(/\s+/).slice(0, 2).join(" "); } + function base64URL(buf) { + return Buffer.from(buf).toString("base64url"); + } + function sshMPIntToBuffer(buf) { + let out = Buffer.from(buf); + while (out.length > 1 && out[0] === 0) out = out.subarray(1); + return out; + } + function ecdsaSignatureToDER(blob) { + let parsed = readSSHString(blob, 0); + const r = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const s = sshMPIntToBuffer(parsed.value); + const encodeInt = (value) => { + let out = Buffer.from(value); + if (out.length === 0) out = Buffer.from([0]); + if (out[0] & 0x80) out = Buffer.concat([Buffer.from([0]), out]); + return Buffer.concat([Buffer.from([0x02, out.length]), out]); + }; + const body = Buffer.concat([encodeInt(r), encodeInt(s)]); + if (body.length > 127) throw new Error("ECDSA signature too large"); + return Buffer.concat([Buffer.from([0x30, body.length]), body]); + } function publicKeyObject(publicKey) { const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== "ssh-ed25519") return crypto.createPublicKey(publicKey); + if (parts.length < 2) throw new Error("invalid SSH public key"); const blob = Buffer.from(parts[1], "base64"); let parsed = readSSHString(blob, 0); - if (parsed.value.toString() !== "ssh-ed25519") throw new Error("unsupported SSH key algorithm"); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from("302a300506032b6570032100", "hex"); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: "der", type: "spki"}); + const alg = parsed.value.toString(); + if (alg !== parts[0]) throw new Error("SSH key algorithm mismatch"); + if (alg === "ssh-ed25519") { + parsed = readSSHString(blob, parsed.offset); + return crypto.createPublicKey({key: {kty: "OKP", crv: "Ed25519", x: base64URL(parsed.value)}, format: "jwk"}); + } + if (alg === "ssh-rsa") { + parsed = readSSHString(blob, parsed.offset); + const e = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const n = sshMPIntToBuffer(parsed.value); + return crypto.createPublicKey({key: {kty: "RSA", n: base64URL(n), e: base64URL(e)}, format: "jwk"}); + } + if (alg.startsWith("ecdsa-sha2-")) { + parsed = readSSHString(blob, parsed.offset); + const sshCurve = parsed.value.toString(); + parsed = readSSHString(blob, parsed.offset); + const point = parsed.value; + const curves = {"nistp256": "P-256", "nistp384": "P-384", "nistp521": "P-521"}; + const crv = curves[sshCurve]; + if (!crv || !point.length || point[0] !== 4) throw new Error("unsupported ECDSA SSH key"); + const coordinateLength = Math.ceil(Number(sshCurve.replace("nistp", "")) / 8); + const x = point.subarray(1, 1 + coordinateLength); + const y = point.subarray(1 + coordinateLength, 1 + 2 * coordinateLength); + if (x.length !== coordinateLength || y.length !== coordinateLength) throw new Error("invalid ECDSA SSH key"); + return crypto.createPublicKey({key: {kty: "EC", crv, x: base64URL(x), y: base64URL(y)}, format: "jwk"}); + } + throw new Error("unsupported SSH key algorithm"); + } + function signatureVerifyAlgorithm(alg) { + if (alg === "ssh-ed25519") return null; + if (alg === "ssh-rsa") return "sha1"; + if (alg === "rsa-sha2-256") return "sha256"; + if (alg === "rsa-sha2-512") return "sha512"; + if (alg === "ecdsa-sha2-nistp256") return "sha256"; + if (alg === "ecdsa-sha2-nistp384") return "sha384"; + if (alg === "ecdsa-sha2-nistp521") return "sha512"; + throw new Error("unsupported SSH signature algorithm"); + } + function signatureBlobForVerify(alg, sig) { + if (alg.startsWith("ecdsa-sha2-")) return ecdsaSignatureToDER(sig); + return sig; + } + function verifySSHSignature(publicKey, message, signature) { + const parsed = readSSHString(Buffer.from(signature, "base64"), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; + return crypto.verify(signatureVerifyAlgorithm(alg), Buffer.from(message, "base64"), publicKeyObject(publicKey), signatureBlobForVerify(alg, sig)); } function signedKey(event, entry) { const keys = (entry.data.keys || []).filter((k) => !k.suspended); @@ -306,11 +375,7 @@ Resources: if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, "base64"), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; - const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; - if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; return key; } function submittedSignedKey(event) { @@ -318,11 +383,7 @@ Resources: const message = String(header(event, "x-bgit-signature-message")); const signature = String(header(event, "x-bgit-signature")); if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; - const parsed = readSSHString(Buffer.from(signature, "base64"), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; - const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; - if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; } function ownershipTransferCode(brokerURL, repo, token) { @@ -489,6 +550,12 @@ Resources: ]}); } async function objectCapability(repo, objectPath, operation, key) { + if (process.env.BROKER_TEST_MODE === "sqlite") { + const action = operation === "write" ? "PUT" : operation === "delete" ? "DELETE" : "GET"; + const object = objectName(repo, objectPath); + const url = String(process.env.BROKER_TEST_BASE_URL || "").replace(/\/+$/g, "") + "/_objects/" + encodeURIComponent(repo.bucket) + "/" + Buffer.from(object).toString("base64url") + "?method=" + encodeURIComponent(action); + return {provider: "test", mode: "signed_url", method: action, url, headers: operation === "write" ? {"content-type": "application/octet-stream"} : {}, bucket: repo.bucket, prefix: repo.prefix, object, region: process.env.AWS_REGION, expires_in: 600}; + } const out = await sts.send(new AssumeRoleCommand({ RoleArn: transferRoleArn, RoleSessionName: `bgit-${operation}-${Date.now()}`, @@ -829,6 +896,8 @@ Resources: const brokerURL = String(body.broker_url || "").trim(); const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || "") > Date.now()); + const normalizedUser = user.toLowerCase(); + if (entry.data.invites.some((invite) => String(invite.user || "").trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error("invite already pending for user"), {statusCode: 409}); entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); audit(entry, {type: "member_invite_create", user, role}); @@ -857,6 +926,22 @@ Resources: await saveRepo(entry); return response(200, {ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); } + if (path === "/keys/invite/cancel" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const invites = entry.data.invites || []; + const user = String(body.user || "").trim().toLowerCase(); + const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ""; + const next = invites.filter((item) => { + if (tokenHash) return item.token_hash !== tokenHash; + return String(item.user || "").trim().toLowerCase() !== user; + }); + if (next.length === invites.length) throw Object.assign(new Error("invite is not pending or has expired"), {statusCode: 404}); + entry.data.invites = next; + audit(entry, {type: "member_invite_cancel"}); + await saveRepo(entry); + return response(200, {ok: true}); + } if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { const entry = await loadRepo(body.repo); requireAdmin(event, entry); diff --git a/broker/gcp/index.js b/broker/gcp/index.js index fafc668..f351aeb 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -42,7 +42,7 @@ async function loadRepo(repo) { } async function saveRepo(entry) { - await entry.ref.set(entry.data, {merge: true}); + await entry.ref.set(entry.data, {merge: false}); await syncMembershipIndex(entry); } @@ -132,8 +132,10 @@ function audit(entry, event) { } function readSSHString(buf, offset) { + if (offset + 4 > buf.length) throw new Error('invalid SSH wire string'); const len = buf.readUInt32BE(offset); const start = offset + 4; + if (start + len > buf.length) throw new Error('invalid SSH wire string'); return {value: buf.subarray(start, start + len), offset: start + len}; } @@ -151,16 +153,88 @@ function normalizeKey(key) { return String(key || '').trim().split(/\s+/).slice(0, 2).join(' '); } +function base64URL(buf) { + return Buffer.from(buf).toString('base64url'); +} + +function sshMPIntToBuffer(buf) { + let out = Buffer.from(buf); + while (out.length > 1 && out[0] === 0) out = out.subarray(1); + return out; +} + +function ecdsaSignatureToDER(blob) { + let parsed = readSSHString(blob, 0); + const r = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const s = sshMPIntToBuffer(parsed.value); + const encodeInt = (value) => { + let out = Buffer.from(value); + if (out.length === 0) out = Buffer.from([0]); + if (out[0] & 0x80) out = Buffer.concat([Buffer.from([0]), out]); + return Buffer.concat([Buffer.from([0x02, out.length]), out]); + }; + const body = Buffer.concat([encodeInt(r), encodeInt(s)]); + if (body.length > 127) throw new Error('ECDSA signature too large'); + return Buffer.concat([Buffer.from([0x30, body.length]), body]); +} + function publicKeyObject(publicKey) { const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== 'ssh-ed25519') return crypto.createPublicKey(publicKey); + if (parts.length < 2) throw new Error('invalid SSH public key'); const blob = Buffer.from(parts[1], 'base64'); let parsed = readSSHString(blob, 0); const alg = parsed.value.toString(); - if (alg !== 'ssh-ed25519') throw new Error('unsupported SSH key algorithm'); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from('302a300506032b6570032100', 'hex'); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: 'der', type: 'spki'}); + if (alg !== parts[0]) throw new Error('SSH key algorithm mismatch'); + if (alg === 'ssh-ed25519') { + parsed = readSSHString(blob, parsed.offset); + return crypto.createPublicKey({key: {kty: 'OKP', crv: 'Ed25519', x: base64URL(parsed.value)}, format: 'jwk'}); + } + if (alg === 'ssh-rsa') { + parsed = readSSHString(blob, parsed.offset); + const e = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const n = sshMPIntToBuffer(parsed.value); + return crypto.createPublicKey({key: {kty: 'RSA', n: base64URL(n), e: base64URL(e)}, format: 'jwk'}); + } + if (alg.startsWith('ecdsa-sha2-')) { + parsed = readSSHString(blob, parsed.offset); + const sshCurve = parsed.value.toString(); + parsed = readSSHString(blob, parsed.offset); + const point = parsed.value; + const curves = {'nistp256': 'P-256', 'nistp384': 'P-384', 'nistp521': 'P-521'}; + const crv = curves[sshCurve]; + if (!crv || !point.length || point[0] !== 4) throw new Error('unsupported ECDSA SSH key'); + const coordinateLength = Math.ceil(Number(sshCurve.replace('nistp', '')) / 8); + const x = point.subarray(1, 1 + coordinateLength); + const y = point.subarray(1 + coordinateLength, 1 + 2 * coordinateLength); + if (x.length !== coordinateLength || y.length !== coordinateLength) throw new Error('invalid ECDSA SSH key'); + return crypto.createPublicKey({key: {kty: 'EC', crv, x: base64URL(x), y: base64URL(y)}, format: 'jwk'}); + } + throw new Error('unsupported SSH key algorithm'); +} + +function signatureVerifyAlgorithm(alg) { + if (alg === 'ssh-ed25519') return null; + if (alg === 'ssh-rsa') return 'sha1'; + if (alg === 'rsa-sha2-256') return 'sha256'; + if (alg === 'rsa-sha2-512') return 'sha512'; + if (alg === 'ecdsa-sha2-nistp256') return 'sha256'; + if (alg === 'ecdsa-sha2-nistp384') return 'sha384'; + if (alg === 'ecdsa-sha2-nistp521') return 'sha512'; + throw new Error('unsupported SSH signature algorithm'); +} + +function signatureBlobForVerify(alg, sig) { + if (alg.startsWith('ecdsa-sha2-')) return ecdsaSignatureToDER(sig); + return sig; +} + +function verifySSHSignature(publicKey, message, signature) { + const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; + return crypto.verify(signatureVerifyAlgorithm(alg), Buffer.from(message, 'base64'), publicKeyObject(publicKey), signatureBlobForVerify(alg, sig)); } function signedKey(req, entry) { @@ -171,11 +245,7 @@ function signedKey(req, entry) { if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; - const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; - if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; return key; } @@ -184,11 +254,7 @@ function submittedSignedKey(req) { const message = String(req.get('x-bgit-signature-message') || ''); const signature = String(req.get('x-bgit-signature') || ''); if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; - const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; - const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; - if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; } @@ -767,6 +833,8 @@ exports.broker = async (req, res) => { const brokerURL = String(body.broker_url || '').trim(); const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || '') > Date.now()); + const normalizedUser = user.toLowerCase(); + if (entry.data.invites.some((invite) => String(invite.user || '').trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error('invite already pending for user'), {status: 409}); entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); audit(entry, {type: 'member_invite_create', user, role}); @@ -797,6 +865,23 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint})); return; } + if (req.path === '/keys/invite/cancel' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const invites = entry.data.invites || []; + const user = String(body.user || '').trim().toLowerCase(); + const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ''; + const next = invites.filter((item) => { + if (tokenHash) return item.token_hash !== tokenHash; + return String(item.user || '').trim().toLowerCase() !== user; + }); + if (next.length === invites.length) throw Object.assign(new Error('invite is not pending or has expired'), {status: 404}); + entry.data.invites = next; + audit(entry, {type: 'member_invite_cancel'}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { const entry = await ensureRepo(body.repo); requireAdmin(req, entry); diff --git a/broker/test_support/sqlite_broker.js b/broker/test_support/sqlite_broker.js new file mode 100644 index 0000000..6c98fc1 --- /dev/null +++ b/broker/test_support/sqlite_broker.js @@ -0,0 +1,435 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const {Readable} = require('stream'); +const {DatabaseSync} = require('node:sqlite'); + +function ensureDir(dir) { + fs.mkdirSync(dir, {recursive: true}); +} + +function encodeObjectName(name) { + return Buffer.from(String(name || ''), 'utf8').toString('base64url'); +} + +function decodeObjectName(name) { + return Buffer.from(String(name || ''), 'base64url').toString('utf8'); +} + +class SQLiteStore { + constructor(file) { + ensureDir(path.dirname(file)); + this.db = new DatabaseSync(file); + this.db.exec(` + create table if not exists documents ( + path text primary key, + data text not null + ); + create table if not exists aws_items ( + table_name text not null, + pk text not null, + data text not null, + primary key (table_name, pk) + ); + `); + } + + getDocument(docPath) { + const row = this.db.prepare('select data from documents where path = ?').get(docPath); + return row ? JSON.parse(row.data) : null; + } + + setDocument(docPath, data, merge) { + const current = merge ? this.getDocument(docPath) : null; + const next = current ? {...current, ...data} : data; + this.db.prepare('insert or replace into documents(path, data) values (?, ?)').run(docPath, JSON.stringify(next || {})); + } + + deleteDocument(docPath) { + this.db.prepare('delete from documents where path = ?').run(docPath); + } + + listCollection(collectionPath) { + const prefix = collectionPath.replace(/\/+$/g, '') + '/'; + const rows = this.db.prepare('select path, data from documents where path like ?').all(prefix + '%'); + return rows + .filter((row) => !row.path.slice(prefix.length).includes('/')) + .map((row) => ({id: row.path.slice(prefix.length), path: row.path, data: JSON.parse(row.data)})); + } + + awsGet(tableName, pk) { + const row = this.db.prepare('select data from aws_items where table_name = ? and pk = ?').get(tableName, pk); + return row ? JSON.parse(row.data) : null; + } + + awsPut(tableName, pk, data) { + this.db.prepare('insert or replace into aws_items(table_name, pk, data) values (?, ?, ?)').run(tableName, pk, JSON.stringify(data || {})); + } + + awsDelete(tableName, pk) { + this.db.prepare('delete from aws_items where table_name = ? and pk = ?').run(tableName, pk); + } + + awsScan(tableName) { + return this.db.prepare('select data from aws_items where table_name = ?').all(tableName).map((row) => JSON.parse(row.data)); + } + + awsQuery(tableName, prefix) { + return this.awsScan(tableName).filter((item) => String(item.__pk || '').startsWith(prefix)); + } +} + +class FakeSnapshot { + constructor(ref, value) { + this.ref = ref; + this.id = ref.id; + this.exists = value !== null && value !== undefined; + this._value = value || {}; + } + data() { + return JSON.parse(JSON.stringify(this._value)); + } +} + +class FakeQuerySnapshot { + constructor(docs) { + this.docs = docs; + } + forEach(fn) { + this.docs.forEach(fn); + } +} + +class FakeDocumentRef { + constructor(store, docPath) { + this._store = store; + this.path = docPath; + this.id = docPath.split('/').pop(); + } + async get() { + return new FakeSnapshot(this, this._store.getDocument(this.path)); + } + async set(data, opts = {}) { + this._store.setDocument(this.path, data || {}, !!opts.merge); + } + async delete() { + this._store.deleteDocument(this.path); + } + collection(name) { + return new FakeCollectionRef(this._store, this.path + '/' + name); + } +} + +class FakeCollectionRef { + constructor(store, collectionPath) { + this._store = store; + this.path = collectionPath.replace(/\/+$/g, ''); + this._orderBy = null; + this._direction = 'asc'; + } + doc(id) { + return new FakeDocumentRef(this._store, this.path + '/' + id); + } + orderBy(field, direction) { + const next = new FakeCollectionRef(this._store, this.path); + next._orderBy = field; + next._direction = direction || 'asc'; + return next; + } + async get() { + let docs = this._store.listCollection(this.path).map((row) => new FakeSnapshot(new FakeDocumentRef(this._store, row.path), row.data)); + if (this._orderBy) { + const field = this._orderBy; + const mult = this._direction === 'desc' ? -1 : 1; + docs = docs.sort((a, b) => { + const av = a.data()[field]; + const bv = b.data()[field]; + return av === bv ? 0 : av > bv ? mult : -mult; + }); + } + return new FakeQuerySnapshot(docs); + } +} + +class FakeFirestore { + constructor() { + this._store = new SQLiteStore(process.env.BROKER_TEST_SQLITE || path.join(process.cwd(), 'broker-test.sqlite')); + } + collection(name) { + return new FakeCollectionRef(this._store, name); + } + async runTransaction(fn) { + const tx = { + get: (ref) => ref.get(), + set: (ref, data, opts) => ref.set(data, opts), + }; + return fn(tx); + } +} + +class FakeFile { + constructor(root, bucket, name) { + this.root = root; + this.bucket = bucket; + this.name = name; + } + diskPath() { + return path.join(this.root, this.bucket, this.name); + } + async download() { + return [fs.readFileSync(this.diskPath())]; + } + async save(value) { + ensureDir(path.dirname(this.diskPath())); + fs.writeFileSync(this.diskPath(), value); + } + async delete() { + fs.rmSync(this.diskPath(), {force: true}); + } + async getSignedUrl(opts = {}) { + const method = opts.method || (opts.action === 'write' ? 'PUT' : opts.action === 'delete' ? 'DELETE' : 'GET'); + return [`${process.env.BROKER_TEST_BASE_URL}/_objects/${encodeURIComponent(this.bucket)}/${encodeObjectName(this.name)}?method=${encodeURIComponent(method)}`]; + } + async createResumableUpload() { + return [`${process.env.BROKER_TEST_BASE_URL}/_objects/${encodeURIComponent(this.bucket)}/${encodeObjectName(this.name)}?method=PUT`]; + } +} + +class FakeBucket { + constructor(root, name) { + this.root = root; + this.name = name; + } + file(name) { + return new FakeFile(this.root, this.name, name); + } + async get() { + ensureDir(path.join(this.root, this.name)); + return [this]; + } + async getFiles(opts = {}) { + const bucketRoot = path.join(this.root, this.name); + const prefix = opts.prefix || ''; + if (!fs.existsSync(bucketRoot)) return [[]]; + const out = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + const rel = path.relative(bucketRoot, full).split(path.sep).join('/'); + if (rel.startsWith(prefix)) out.push({name: rel, delete: async () => fs.rmSync(full, {force: true})}); + } + } + }; + walk(bucketRoot); + return [out]; + } + async delete() { + fs.rmSync(path.join(this.root, this.name), {recursive: true, force: true}); + } +} + +class FakeStorage { + constructor() { + this.root = process.env.BROKER_TEST_OBJECT_ROOT || path.join(process.cwd(), 'broker-test-objects'); + ensureDir(this.root); + } + bucket(name) { + return new FakeBucket(this.root, name); + } + async createBucket(name) { + const bucket = this.bucket(name); + await bucket.get(); + return [bucket]; + } +} + +class FakeGoogleAuth { + async getClient() { + return {}; + } + async getProjectId() { + return 'bgit-test'; + } +} + +function awsAttrToValue(attr) { + if (!attr) return undefined; + if (Object.prototype.hasOwnProperty.call(attr, 'S')) return attr.S; + if (Object.prototype.hasOwnProperty.call(attr, 'N')) return Number(attr.N); + return undefined; +} + +function awsItemPK(item) { + if (item.id) return item.id.S; + if (item.repo_id && item.pr_id) return item.repo_id.S + '#' + item.pr_id.N; + if (item.fingerprint && item.repo_id) return item.fingerprint.S + '#' + item.repo_id.S; + return JSON.stringify(item); +} + +function makeAWSModules(store, objectRoot) { + class DynamoDBClient { + async send(command) { + const input = command.input || {}; + const name = command.constructor.name; + if (name === 'GetItemCommand') { + const item = store.awsGet(input.TableName, awsItemPK(input.Key || {})); + return item ? {Item: item} : {}; + } + if (name === 'PutItemCommand') { + const pk = awsItemPK(input.Item || {}); + store.awsPut(input.TableName, pk, {...input.Item, __pk: pk}); + return {}; + } + if (name === 'DeleteItemCommand') { + store.awsDelete(input.TableName, awsItemPK(input.Key || {})); + return {}; + } + if (name === 'ScanCommand') { + return {Items: store.awsScan(input.TableName)}; + } + if (name === 'QueryCommand') { + const values = input.ExpressionAttributeValues || {}; + const fingerprint = awsAttrToValue(values[':fingerprint']); + const repoID = awsAttrToValue(values[':repo_id']); + const prefix = fingerprint ? fingerprint + '#' : repoID ? repoID + '#' : ''; + return {Items: store.awsQuery(input.TableName, prefix)}; + } + throw new Error('unsupported fake DynamoDB command ' + name); + } + } + class GetItemCommand { constructor(input) { this.input = input || {}; } } + class PutItemCommand { constructor(input) { this.input = input || {}; } } + class QueryCommand { constructor(input) { this.input = input || {}; } } + class ScanCommand { constructor(input) { this.input = input || {}; } } + class DeleteItemCommand { constructor(input) { this.input = input || {}; } } + class GetObjectCommand { constructor(input) { this.input = input || {}; } } + class PutObjectCommand { constructor(input) { this.input = input || {}; } } + class DeleteObjectCommand { constructor(input) { this.input = input || {}; } } + class ListObjectsV2Command { constructor(input) { this.input = input || {}; } } + class HeadBucketCommand { constructor(input) { this.input = input || {}; } } + class CreateBucketCommand { constructor(input) { this.input = input || {}; } } + class DeleteBucketCommand { constructor(input) { this.input = input || {}; } } + class AssumeRoleCommand { constructor(input) { this.input = input || {}; } } + class S3Client { + async send(command) { + const input = command.input || {}; + const name = command.constructor.name; + const bucketRoot = path.join(objectRoot, input.Bucket || ''); + const filePath = path.join(bucketRoot, input.Key || ''); + if (name === 'HeadBucketCommand' || name === 'CreateBucketCommand') { + ensureDir(bucketRoot); + return {}; + } + if (name === 'PutObjectCommand') { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, Buffer.isBuffer(input.Body) || typeof input.Body === 'string' ? input.Body : Buffer.from(input.Body || '')); + return {}; + } + if (name === 'GetObjectCommand') { + return {Body: Readable.from(fs.readFileSync(filePath))}; + } + if (name === 'DeleteObjectCommand') { + fs.rmSync(filePath, {force: true}); + return {}; + } + if (name === 'DeleteBucketCommand') { + fs.rmSync(bucketRoot, {recursive: true, force: true}); + return {}; + } + if (name === 'ListObjectsV2Command') { + const contents = []; + if (fs.existsSync(bucketRoot)) { + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else { + const key = path.relative(bucketRoot, full).split(path.sep).join('/'); + if (key.startsWith(input.Prefix || '')) contents.push({Key: key}); + } + } + }; + walk(bucketRoot); + } + return {Contents: contents}; + } + throw new Error('unsupported fake S3 command ' + name); + } + } + class STSClient { + async send() { + return {Credentials: {AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test'}}; + } + } + return { + '@aws-sdk/client-dynamodb': { + DynamoDBClient, + GetItemCommand, + PutItemCommand, + QueryCommand, + ScanCommand, + DeleteItemCommand, + }, + '@aws-sdk/client-s3': { + S3Client, + GetObjectCommand, + PutObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + HeadBucketCommand, + CreateBucketCommand, + DeleteBucketCommand, + }, + '@aws-sdk/client-sts': { + STSClient, + AssumeRoleCommand, + }, + }; +} + +async function handleObjectRequest(req, res, objectRoot) { + const parts = req.url.split('?')[0].split('/').filter(Boolean); + if (parts[0] !== '_objects' || parts.length < 3) return false; + const bucket = decodeURIComponent(parts[1]); + const name = decodeObjectName(parts.slice(2).join('/')); + const filePath = path.join(objectRoot, bucket, name); + if (req.method === 'GET') { + if (!fs.existsSync(filePath)) { + res.writeHead(404).end('not found'); + return true; + } + res.writeHead(200, {'content-type': 'application/octet-stream'}); + fs.createReadStream(filePath).pipe(res); + return true; + } + if (req.method === 'PUT') { + ensureDir(path.dirname(filePath)); + const chunks = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + fs.writeFileSync(filePath, Buffer.concat(chunks)); + res.writeHead(200, {'content-type': 'application/json'}).end('{}'); + }); + return true; + } + if (req.method === 'DELETE') { + fs.rmSync(filePath, {force: true}); + res.writeHead(200, {'content-type': 'application/json'}).end('{}'); + return true; + } + res.writeHead(405).end('method not allowed'); + return true; +} + +module.exports = { + SQLiteStore, + FakeFirestore, + FakeStorage, + FakeGoogleAuth, + makeAWSModules, + handleObjectRequest, +}; diff --git a/broker/testserver.js b/broker/testserver.js new file mode 100644 index 0000000..746d2d8 --- /dev/null +++ b/broker/testserver.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const Module = require('module'); +const path = require('path'); +const vm = require('vm'); +const { + SQLiteStore, + FakeFirestore, + FakeStorage, + FakeGoogleAuth, + makeAWSModules, + handleObjectRequest, +} = require('./test_support/sqlite_broker'); + +const runtime = process.argv[2] || process.env.BROKER_TEST_RUNTIME || 'gcp'; +const port = Number(process.env.PORT || process.env.BROKER_TEST_PORT || 19080); +const root = process.env.BROKER_TEST_ROOT || path.join(process.cwd(), '.broker-test'); +const sqlitePath = process.env.BROKER_TEST_SQLITE || path.join(root, runtime + '.sqlite'); +const objectRoot = process.env.BROKER_TEST_OBJECT_ROOT || path.join(root, runtime + '-objects'); + +fs.mkdirSync(root, {recursive: true}); +fs.mkdirSync(objectRoot, {recursive: true}); +process.env.BROKER_TEST_MODE = 'sqlite'; +process.env.BROKER_TEST_SQLITE = sqlitePath; +process.env.BROKER_TEST_OBJECT_ROOT = objectRoot; +process.env.BROKER_VERSION = process.env.BROKER_VERSION || '1.0.0-test'; +process.env.TABLE_NAME = process.env.TABLE_NAME || 'bgit-broker-repos'; +process.env.PR_TABLE_NAME = process.env.PR_TABLE_NAME || 'bgit-broker-prs'; +process.env.MEMBER_TABLE_NAME = process.env.MEMBER_TABLE_NAME || 'bgit-broker-members'; +process.env.TRANSFER_ROLE_ARN = process.env.TRANSFER_ROLE_ARN || 'arn:aws:iam::000000000000:role/bgit-test-transfer'; +process.env.AWS_REGION = process.env.AWS_REGION || 'us-east-1'; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function installGCPMocks() { + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (request === '@google-cloud/firestore') return {Firestore: FakeFirestore}; + if (request === '@google-cloud/storage') return {Storage: FakeStorage}; + if (request === 'google-auth-library') return {GoogleAuth: FakeGoogleAuth}; + return originalLoad.call(this, request, parent, isMain); + }; +} + +function loadGCPHandler() { + installGCPMocks(); + return require('./gcp/index.js').broker; +} + +function awsZipFileSource() { + const template = fs.readFileSync(path.join(__dirname, 'aws/template.yaml'), 'utf8').split(/\r?\n/); + const start = template.findIndex((line) => line.includes('ZipFile: |')); + if (start < 0) throw new Error('AWS template ZipFile not found'); + const lines = []; + for (let i = start + 1; i < template.length; i++) { + const line = template[i]; + if (/^ BrokerFunctionUrl:/.test(line)) break; + lines.push(line.replace(/^ /, '')); + } + return lines.join('\n'); +} + +function loadAWSHandler() { + const store = new SQLiteStore(sqlitePath); + const awsModules = makeAWSModules(store, objectRoot); + const sandbox = { + exports: {}, + module: {exports: {}}, + require: (name) => { + if (awsModules[name]) return awsModules[name]; + return require(name); + }, + process, + Buffer, + console, + setTimeout, + clearTimeout, + URL, + }; + sandbox.module.exports = sandbox.exports; + vm.runInNewContext(awsZipFileSource(), sandbox, {filename: 'broker/aws/template.js'}); + return sandbox.exports.handler || sandbox.module.exports.handler; +} + +let server; + +function gcpResponse(res) { + return { + set: (key, value) => res.setHeader(key, value), + status(code) { + res.statusCode = code; + return this; + }, + send(value) { + res.end(value); + }, + }; +} + +async function handleGCP(handler, req, res, raw) { + const bodyText = raw.toString('utf8'); + const gcpReq = { + path: new URL(req.url, process.env.BROKER_TEST_BASE_URL).pathname, + method: req.method, + headers: req.headers, + rawBody: raw, + body: bodyText ? JSON.parse(bodyText) : {}, + get: (name) => req.headers[String(name).toLowerCase()], + }; + await handler(gcpReq, gcpResponse(res)); +} + +async function handleAWS(handler, req, res, raw) { + const event = { + rawPath: new URL(req.url, process.env.BROKER_TEST_BASE_URL).pathname, + requestContext: {http: {method: req.method}}, + headers: req.headers, + body: raw.toString('utf8'), + }; + const out = await handler(event); + res.writeHead(out.statusCode || 200, out.headers || {'content-type': 'application/json'}); + res.end(out.body || ''); +} + +async function main() { + const handler = runtime === 'aws' ? loadAWSHandler() : loadGCPHandler(); + server = http.createServer(async (req, res) => { + try { + if (await handleObjectRequest(req, res, objectRoot)) return; + const raw = await readBody(req); + if (runtime === 'aws') await handleAWS(handler, req, res, raw); + else await handleGCP(handler, req, res, raw); + } catch (err) { + res.writeHead(err.statusCode || err.status || 500, {'content-type': 'application/json'}); + res.end(JSON.stringify({error: err.message || String(err)})); + } + }); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + const address = server.address(); + process.env.BROKER_TEST_BASE_URL = `http://127.0.0.1:${address.port}`; + console.log(`bgit test broker ${runtime} listening on ${process.env.BROKER_TEST_BASE_URL}`); +} + +process.on('SIGTERM', () => server && server.close(() => process.exit(0))); +process.on('SIGINT', () => server && server.close(() => process.exit(130))); + +main().catch((err) => { + console.error(err && err.stack || err); + process.exit(1); +}); + diff --git a/broker_commands.go b/broker_commands.go index 8fd4c4e..4a4fdb0 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -31,7 +31,7 @@ func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit admin keys|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer [args]\n\nCloud IAM administration moved to bgit direct admin.") + return errors.New("usage: bgit admin keys|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer|invite-user|accept-invite|cancel-invite [args]\n\nCloud IAM administration moved to bgit direct admin.") } switch args[0] { case "keys": @@ -50,6 +50,8 @@ func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, std return brokerInviteUserCommand(cfg, args[1:], stdout) case "accept-invite": return brokerAcceptInviteCommand(args[1:], stdout) + case "cancel-invite": + return brokerCancelInviteCommand(cfg, args[1:], stdout) case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": return errors.New("cloud IAM administration moved to bgit direct admin") default: @@ -676,6 +678,66 @@ func brokerAcceptInviteCommand(args []string, stdout io.Writer) error { return nil } +func brokerCancelInviteCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, user, err := parseCancelInviteTarget(cfg, args) + if err != nil { + return err + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + if err := brokerPost(brokerURL, "/keys/invite/cancel", brokerOwnerTransferRequest{Repo: repo, User: user}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "cancelled invite for %s on %s\n", user, repo.Logical) + return nil +} + +func parseCancelInviteTarget(cfg config, args []string) (string, string, string, error) { + brokerURL := "" + repoName := "" + user := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", "", err + } + brokerURL = strings.TrimSpace(value) + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", "", err + } + user = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return "", "", "", fmt.Errorf("unsupported cancel-invite option %s", arg) + } + if repoName != "" { + return "", "", "", errors.New("cancel-invite accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" || repoName == "" { + if local, err := configForBrokerCommand(cfg); err == nil { + if brokerURL == "" { + brokerURL = local.brokerURL + } + if repoName == "" { + repoName = local.logicalRepo + } + } + } + if brokerURL == "" || repoName == "" || user == "" { + return "", "", "", errors.New("usage: bgit admin cancel-invite --broker URL --user USER REPO") + } + return brokerURL, repoName, user, nil +} + func parseInviteCode(code string) (ownerTransferCodePayload, error) { code = strings.TrimSpace(code) if !strings.HasPrefix(code, "bgitinv_") { @@ -948,7 +1010,7 @@ func parseIssueIDArg(args []string) (int, error) { func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { _ = stdin if len(args) == 0 { - return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close|reopen [args]") + return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close|reopen|comment|approve|reject [args]") } cfg, err := configForBrokerCommand(config{}) if err != nil { @@ -999,15 +1061,51 @@ func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { fmt.Fprintf(stdout, "reopened PR #%d\n", id) return nil case "merge": - id, err := parsePRIDArg(args[1:]) + deleteBranch := false + var idArgs []string + for _, arg := range args[1:] { + switch arg { + case "--delete-branch": + deleteBranch = true + default: + idArgs = append(idArgs, arg) + } + } + id, err := parsePRIDArg(idArgs) if err != nil { return err } - if err := brokerPost(cfg.brokerURL, "/prs/merge", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Merge: true}, nil); err != nil { + if err := brokerPost(cfg.brokerURL, "/prs/merge", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Merge: true, DeleteBranch: deleteBranch}, nil); err != nil { return err } fmt.Fprintf(stdout, "merged PR #%d\n", id) return nil + case "comment": + id, comment, err := parsePRIDAndTextArg(args[1:], "usage: bgit pr comment ID COMMENT") + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/comment", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "commented on PR #%d\n", id) + return nil + case "approve", "reject": + id, comment, err := parsePRIDAndOptionalTextArg(args[1:]) + if err != nil { + return err + } + review := "approved" + verb := "approved" + if args[0] == "reject" { + review = "changes_requested" + verb = "requested changes on" + } + if err := brokerPost(cfg.brokerURL, "/prs/review", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Review: review, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "%s PR #%d\n", verb, id) + return nil case "checkout": pr, err := brokerGetPullRequest(cfg, args[1:]) if err != nil { @@ -1039,6 +1137,32 @@ func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { } } +func parsePRIDAndTextArg(args []string, usage string) (int, string, error) { + if len(args) < 2 { + return 0, "", errors.New(usage) + } + id, err := strconv.Atoi(args[0]) + if err != nil || id <= 0 { + return 0, "", errors.New("pull request id is required") + } + text := strings.TrimSpace(strings.Join(args[1:], " ")) + if text == "" { + return 0, "", errors.New(usage) + } + return id, text, nil +} + +func parsePRIDAndOptionalTextArg(args []string) (int, string, error) { + if len(args) < 1 { + return 0, "", errors.New("pull request id is required") + } + id, err := strconv.Atoi(args[0]) + if err != nil || id <= 0 { + return 0, "", errors.New("pull request id is required") + } + return id, strings.TrimSpace(strings.Join(args[1:], " ")), nil +} + func prCreateCommand(cfg config, args []string, stdout io.Writer) error { pr := brokerPullRequest{Target: "refs/heads/main"} for i := 0; i < len(args); i++ { diff --git a/git_receive.go b/git_receive.go index 4cc839d..feb61e7 100644 --- a/git_receive.go +++ b/git_receive.go @@ -276,6 +276,9 @@ func applyReceivePackCommands(ctx context.Context, repo *nativeGitRepo, store wr } } else { delete(refs, cmd.ref) + if strings.HasPrefix(cmd.ref, "refs/heads/") { + _ = unsetGitBranchTracking(".", strings.TrimPrefix(cmd.ref, "refs/heads/")) + } } case "noop": default: diff --git a/main.go b/main.go index 4ec5a97..3dd89dc 100644 --- a/main.go +++ b/main.go @@ -519,6 +519,9 @@ func mergeConfig(primary, fallback config) config { if primary.logicalRepo == "" { primary.logicalRepo = fallback.logicalRepo } + if primary.region == "" { + primary.region = fallback.region + } if primary.branch == "" || primary.branch == defaultBranch { primary.branch = fallback.branch } @@ -1027,6 +1030,7 @@ Configure a direct bucketgit origin using Git remote syntax. bgit admin keys list|add|remove|suspend|import-github [args] bgit admin invite-user --broker URL --user USER [--role ROLE] REPO bgit admin accept-invite CODE + bgit admin cancel-invite --broker URL --user USER REPO bgit admin confirm-ownership-transfer --broker URL REPO bgit admin accept-ownership-transfer CODE bgit admin cancel-ownership-transfer [--broker URL REPO] @@ -1066,8 +1070,11 @@ creation; private repositories require membership. bgit pr view ID bgit pr checkout ID bgit pr diff ID - bgit pr merge ID + bgit pr merge ID [--delete-branch] bgit pr close ID + bgit pr comment ID COMMENT + bgit pr approve ID [COMMENT] + bgit pr reject ID [COMMENT] Broker-backed pull request metadata and merge/ref protection workflow. Pull requests are stored in the broker control plane, not in Git itself. diff --git a/main_test.go b/main_test.go index fac282c..62fa3d4 100644 --- a/main_test.go +++ b/main_test.go @@ -1272,6 +1272,29 @@ func TestBrokerSignatureMessageIsStable(t *testing.T) { } } +func TestBrokerForbiddenAllowsSignatureRetryOnlyForAuthFailures(t *testing.T) { + for _, msg := range []string{ + `{"error":"write SSH signature required"}`, + `{"error":"owner SSH signature required"}`, + `admin SSH signature required`, + } { + if !brokerForbiddenAllowsSignatureRetry(msg) { + t.Fatalf("expected auth retry for %q", msg) + } + } + for _, msg := range []string{ + `{"error":"protected branch refs/heads/main requires a pull request"}`, + `{"error":"repository is read-only"}`, + `{"error":"owners cannot be removed or suspended"}`, + `forbidden`, + ``, + } { + if brokerForbiddenAllowsSignatureRetry(msg) { + t.Fatalf("did not expect auth retry for %q", msg) + } + } +} + func TestMergeConfigUsesRepoAuthUnlessExplicit(t *testing.T) { local := config{auth: "adc", gcloudConfiguration: "test-profile"} merged := mergeConfig(config{auth: "gcloud"}, local) @@ -1284,6 +1307,18 @@ func TestMergeConfigUsesRepoAuthUnlessExplicit(t *testing.T) { } } +func TestMergeConfigUsesRepoRegion(t *testing.T) { + local := config{region: "eu-west-1"} + merged := mergeConfig(config{}, local) + if merged.region != "eu-west-1" { + t.Fatalf("merged region = %q", merged.region) + } + merged = mergeConfig(config{region: "us-west-2"}, local) + if merged.region != "us-west-2" { + t.Fatalf("explicit region = %q", merged.region) + } +} + func TestDefaultAWSRegion(t *testing.T) { t.Setenv("AWS_REGION", "") t.Setenv("AWS_DEFAULT_REGION", "") @@ -1300,6 +1335,27 @@ func TestDefaultAWSRegion(t *testing.T) { } } +func TestAWSRegionPrefersExplicitConfig(t *testing.T) { + t.Setenv("AWS_REGION", "eu-central-1") + t.Setenv("AWS_DEFAULT_REGION", "eu-west-1") + if got := awsRegion(config{region: "ap-southeast-2"}); got != "ap-southeast-2" { + t.Fatalf("explicit region = %q", got) + } + if got := awsRegion(config{}); got != "eu-central-1" { + t.Fatalf("fallback region = %q", got) + } +} + +func TestAnonymousS3ClientUsesExplicitRegion(t *testing.T) { + client, err := newS3Client(context.Background(), config{region: "ap-southeast-2"}, true) + if err != nil { + t.Fatal(err) + } + if got := client.Options().Region; got != "ap-southeast-2" { + t.Fatalf("client region = %q", got) + } +} + func TestInitEmptyWorktreeDoesNotRequireOrigin(t *testing.T) { target := filepath.Join(t.TempDir(), "repo") var stdout bytes.Buffer diff --git a/native_git.go b/native_git.go index 2abb1e3..69e9c04 100644 --- a/native_git.go +++ b/native_git.go @@ -489,7 +489,7 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts } updateRef := func(ref, oldHash, newHash string) error { if brokerURL != "" { - if err := brokerUpdateRef(brokerURL, r.cfg, ref, oldHash, newHash); err != nil { + if err := brokerUpdateRefWithOverride(brokerURL, r.cfg, ref, oldHash, newHash, opts.force); err != nil { return brokerPushError(err) } } diff --git a/s3_store.go b/s3_store.go index 4e346cb..58c332e 100644 --- a/s3_store.go +++ b/s3_store.go @@ -24,14 +24,18 @@ type s3GitStore struct { } func newS3Client(ctx context.Context, cfg config, anonymous bool) (*s3.Client, error) { + region := awsRegion(cfg) if anonymous { return s3.New(s3.Options{ - Region: defaultAWSRegion(), + Region: region, Credentials: aws.AnonymousCredentials{}, }), nil } opts := []func(*awsconfig.LoadOptions) error{ - awsconfig.WithDefaultRegion(defaultAWSRegion()), + awsconfig.WithDefaultRegion(region), + } + if strings.TrimSpace(cfg.region) != "" { + opts = append(opts, awsconfig.WithRegion(region)) } if strings.TrimSpace(cfg.gcloudConfiguration) != "" { opts = append(opts, awsconfig.WithSharedConfigProfile(strings.TrimSpace(cfg.gcloudConfiguration))) @@ -43,6 +47,13 @@ func newS3Client(ctx context.Context, cfg config, anonymous bool) (*s3.Client, e return s3.NewFromConfig(awsCfg), nil } +func awsRegion(cfg config) string { + if value := strings.TrimSpace(cfg.region); value != "" { + return value + } + return defaultAWSRegion() +} + func defaultAWSRegion() string { if value := strings.TrimSpace(os.Getenv("AWS_REGION")); value != "" { return value @@ -130,7 +141,7 @@ func ensureS3Bucket(ctx context.Context, cfg config) error { return fmt.Errorf("check bucket s3://%s: %w", cfg.bucket, err) } input := &s3.CreateBucketInput{Bucket: aws.String(cfg.bucket)} - region := defaultAWSRegion() + region := awsRegion(cfg) if region != "" && region != "us-east-1" { input.CreateBucketConfiguration = &types.CreateBucketConfiguration{ LocationConstraint: types.BucketLocationConstraint(region), diff --git a/ssh.go b/ssh.go index 61e3db8..5fa4815 100644 --- a/ssh.go +++ b/ssh.go @@ -577,10 +577,11 @@ type brokerAuthResponse struct { } type brokerRefUpdateRequest struct { - Repo brokerRepo `json:"repo"` - Ref string `json:"ref"` - Old string `json:"old"` - New string `json:"new"` + Repo brokerRepo `json:"repo"` + Ref string `json:"ref"` + Old string `json:"old"` + New string `json:"new"` + Override bool `json:"override,omitempty"` } type brokerKeysResponse struct { @@ -648,11 +649,16 @@ func normalizeBrokerRole(role string) string { } func brokerUpdateRef(brokerURL string, cfg config, ref, oldHash, newHash string) error { + return brokerUpdateRefWithOverride(brokerURL, cfg, ref, oldHash, newHash, false) +} + +func brokerUpdateRefWithOverride(brokerURL string, cfg config, ref, oldHash, newHash string, override bool) error { req := brokerRefUpdateRequest{ - Repo: repoForBroker(cfg), - Ref: ref, - Old: firstNonEmpty(strings.TrimSpace(oldHash), zeroObjectID()), - New: firstNonEmpty(strings.TrimSpace(newHash), zeroObjectID()), + Repo: repoForBroker(cfg), + Ref: ref, + Old: firstNonEmpty(strings.TrimSpace(oldHash), zeroObjectID()), + New: firstNonEmpty(strings.TrimSpace(newHash), zeroObjectID()), + Override: override, } return brokerPost(brokerURL, "/refs/update", req, nil) } @@ -778,13 +784,21 @@ func brokerPostContext(ctx context.Context, brokerURL, path string, req any, res msg = httpResp.Status } lastErr = fmt.Errorf("broker %s: %s", path, msg) - if httpResp.StatusCode != http.StatusForbidden || i == len(headerSets)-1 { + if httpResp.StatusCode != http.StatusForbidden || i == len(headerSets)-1 || !brokerForbiddenAllowsSignatureRetry(msg) { return lastErr } } return lastErr } +func brokerForbiddenAllowsSignatureRetry(msg string) bool { + msg = strings.ToLower(strings.TrimSpace(msg)) + if msg == "" { + return false + } + return strings.Contains(msg, "ssh signature required") +} + func brokerSignatureHeaders(payload []byte) map[string]string { sets := brokerSignatureHeaderSetsForBroker("", payload) if len(sets) == 0 { diff --git a/testsuite/README.md b/testsuite/README.md new file mode 100644 index 0000000..3c784b3 --- /dev/null +++ b/testsuite/README.md @@ -0,0 +1,50 @@ +# BucketGit Integration Test Suite + +This suite exercises the built `bgit` binary against real local repositories and +local SQLite-backed broker runtimes. It intentionally lives outside the Go unit +tests because it starts broker servers, creates repositories, and runs full CLI +flows. + +Run everything locally: + +```bash +./testsuite/run.sh +``` + +Run one provider locally: + +```bash +BGIT_TEST_PROVIDER=gcp ./testsuite/run.sh +BGIT_TEST_PROVIDER=aws ./testsuite/run.sh +``` + +Run a broker runtime directly: + +```bash +./testsuite/run-local-broker.sh gcp +./testsuite/run-local-broker.sh aws +``` + +The local broker runner executes the real GCP Cloud Functions broker module or +the real AWS Lambda broker code extracted from the CloudFormation template. It +uses SQLite for broker metadata and local HTTP object capability URLs for Git +objects, so it does not require cloud credentials and never touches deployed +brokers. + +Useful overrides: + +```bash +BGIT_TEST_PROVIDER=gcp|aws|all +BGIT_TEST_RUN_ID=20260519092710 +BGIT_TEST_LOCAL_BROKER_ROOT=/tmp/bgit-local-broker +BGIT_TEST_KEEP_ARTIFACTS=1 +``` + +The suite creates throwaway worktrees under `testsuite/gcp/repo/`, +`testsuite/aws/repo/`, and `testsuite/local/`. Test SSH identities are under +`testsuite/sshkeys/`; these are generated fixtures only and must never be used +outside the test suite. + +On success, the local broker runner removes its temporary broker root and the +worktrees for the provider it ran. On failure it keeps them for debugging. Set +`BGIT_TEST_KEEP_ARTIFACTS=1` to keep artifacts after successful runs too. diff --git a/testsuite/aws/admin_keys_invites.sh b/testsuite/aws/admin_keys_invites.sh new file mode 100755 index 0000000..21d1414 --- /dev/null +++ b/testsuite/aws/admin_keys_invites.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws invite)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "owner" +out="$(run_in "$dir" admin invite-user --broker "$broker" --user developer --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +out="$(cd "$dir" && expect_failure "$BGIT" admin invite-user --broker "$broker" --user bad --role owner "$repo")" +assert_contains "$out" "invalid role" diff --git a/testsuite/aws/admin_repo.sh b/testsuite/aws/admin_repo.sh new file mode 100755 index 0000000..a68426e --- /dev/null +++ b/testsuite/aws/admin_repo.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws adminrepo)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo visibility private >/dev/null +run_in "$dir" admin repo issues on >/dev/null +run_in "$dir" admin repo readonly off >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" admin repo readonly maybe)" +assert_contains "$out" "usage: bgit admin repo readonly on|off" +new_name="$(new_repo_name aws adminrepo-renamed)" +run_in "$dir" admin repo rename "$new_name" >/dev/null +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$new_name.git" diff --git a/testsuite/aws/branch_protection.sh b/testsuite/aws/branch_protection.sh new file mode 100644 index 0000000..9e3bb6c --- /dev/null +++ b/testsuite/aws/branch_protection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/branch_protection.sh" aws diff --git a/testsuite/aws/danger_zone.sh b/testsuite/aws/danger_zone.sh new file mode 100644 index 0000000..17fd43e --- /dev/null +++ b/testsuite/aws/danger_zone.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/danger_zone.sh" aws diff --git a/testsuite/aws/identity_selection.sh b/testsuite/aws/identity_selection.sh new file mode 100644 index 0000000..d9143f8 --- /dev/null +++ b/testsuite/aws/identity_selection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/identity_selection.sh" aws diff --git a/testsuite/aws/init.sh b/testsuite/aws/init.sh new file mode 100755 index 0000000..dec7066 --- /dev/null +++ b/testsuite/aws/init.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws init)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +assert_file_exists "$dir/.git/config" +out="$(git -C "$dir" config --get bucketgit.logicalRepo)" +assert_contains "$out" "$repo" +out="$(git -C "$dir" config --get bucketgit.broker)" +assert_contains "$out" "http" diff --git a/testsuite/aws/invites_ownership.sh b/testsuite/aws/invites_ownership.sh new file mode 100644 index 0000000..5104639 --- /dev/null +++ b/testsuite/aws/invites_ownership.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/invites_ownership.sh" aws diff --git a/testsuite/aws/issues.sh b/testsuite/aws/issues.sh new file mode 100755 index 0000000..21fc579 --- /dev/null +++ b/testsuite/aws/issues.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws issues)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo issues on >/dev/null +out="$(run_in "$dir" issue create "GCP test issue" --body "created by testsuite")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +assert_contains "$(run_in "$dir" issue list)" "GCP test issue" +run_in "$dir" issue comment "$id" "comment from testsuite" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "comment from testsuite" +run_in "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" diff --git a/testsuite/aws/issues_permissions.sh b/testsuite/aws/issues_permissions.sh new file mode 100644 index 0000000..16042fb --- /dev/null +++ b/testsuite/aws/issues_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/issues_permissions.sh" aws diff --git a/testsuite/aws/native_git_transport.sh b/testsuite/aws/native_git_transport.sh new file mode 100644 index 0000000..6217135 --- /dev/null +++ b/testsuite/aws/native_git_transport.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/native_git_transport.sh" aws diff --git a/testsuite/aws/pr.sh b/testsuite/aws/pr.sh new file mode 100755 index 0000000..b1fcd3d --- /dev/null +++ b/testsuite/aws/pr.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws pr)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp pr" "initial" +run_in "$dir" push -u origin main >/dev/null +run_in "$dir" checkout -b feature/testsuite >/dev/null +commit_file "$dir" FEATURE.md "feature" "feature commit" +run_in "$dir" push -u origin feature/testsuite >/dev/null +out="$(run_in "$dir" pr create --title "GCP testsuite PR" --body "body" --source feature/testsuite --target main)" +assert_contains "$out" "created PR #" +assert_contains "$(run_in "$dir" pr list)" "GCP testsuite PR" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "GCP testsuite PR" +assert_contains "$(run_in "$dir" pr diff "$id")" "FEATURE.md" +out="$(run_in "$dir" pr close "$id")" +assert_contains "$out" "closed" diff --git a/testsuite/aws/pr_depth.sh b/testsuite/aws/pr_depth.sh new file mode 100644 index 0000000..b618213 --- /dev/null +++ b/testsuite/aws/pr_depth.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/pr_depth.sh" aws diff --git a/testsuite/aws/public_private_access.sh b/testsuite/aws/public_private_access.sh new file mode 100644 index 0000000..3e9c50c --- /dev/null +++ b/testsuite/aws/public_private_access.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/public_private_access.sh" aws diff --git a/testsuite/aws/push_fetch_pull_lsremote.sh b/testsuite/aws/push_fetch_pull_lsremote.sh new file mode 100755 index 0000000..cbaa10c --- /dev/null +++ b/testsuite/aws/push_fetch_pull_lsremote.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws remote)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp remote" "initial" +out="$(run_in "$dir" push -u origin main)" +assert_contains "$out" "main -> main" +out="$(run_in "$dir" ls-remote)" +assert_contains "$out" "refs/heads/main" +clone="$SUITE_ROOT/aws/repo/remote-clone-$RUN_ID" +rm -rf "$clone" +expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +assert_file_exists "$clone/README.md" +printf 'gcp pull\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "gcp pull source" >/dev/null +run_in "$dir" push >/dev/null +out="$(run_in "$clone" pull)" +assert_contains "$out" "README.md" diff --git a/testsuite/aws/roles_permissions.sh b/testsuite/aws/roles_permissions.sh new file mode 100644 index 0000000..e5873b6 --- /dev/null +++ b/testsuite/aws/roles_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/roles_permissions.sh" aws diff --git a/testsuite/aws/ssh_key_types.sh b/testsuite/aws/ssh_key_types.sh new file mode 100755 index 0000000..31a7a37 --- /dev/null +++ b/testsuite/aws/ssh_key_types.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws keytypes)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" + +accept_with_key() { + local label="$1" + local key_path="$2" + local out code + out="$(run_in "$dir" admin invite-user --broker "$broker" --user "$label" --role read "$repo")" + code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" + [[ "$code" == bgitinv_* ]] || fail "invite code not found in output: $out" + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + ssh-add "$key_path" >/dev/null + cd "$dir" + "$BGIT" admin accept-invite "$code" >/dev/null + ) +} + +accept_with_key ed25519 "$SUITE_ROOT/sshkeys/developer" +accept_with_key rsa "$SUITE_ROOT/sshkeys/rsa_owner" +accept_with_key ecdsa "$SUITE_ROOT/sshkeys/ecdsa_owner" + +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "ed25519" +assert_contains "$out" "rsa" +assert_contains "$out" "ecdsa" diff --git a/testsuite/aws/whoami_repos.sh b/testsuite/aws/whoami_repos.sh new file mode 100755 index 0000000..61800ea --- /dev/null +++ b/testsuite/aws/whoami_repos.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws whoami)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +out="$(run_in "$dir" whoami)" +assert_contains "$out" "role" +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$repo" diff --git a/testsuite/gcp/admin_keys_invites.sh b/testsuite/gcp/admin_keys_invites.sh new file mode 100755 index 0000000..334771d --- /dev/null +++ b/testsuite/gcp/admin_keys_invites.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp invite)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "owner" +out="$(run_in "$dir" admin invite-user --broker "$broker" --user developer --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +out="$(cd "$dir" && expect_failure "$BGIT" admin invite-user --broker "$broker" --user bad --role owner "$repo")" +assert_contains "$out" "invalid role" diff --git a/testsuite/gcp/admin_repo.sh b/testsuite/gcp/admin_repo.sh new file mode 100755 index 0000000..0fe45b7 --- /dev/null +++ b/testsuite/gcp/admin_repo.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp adminrepo)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo visibility private >/dev/null +run_in "$dir" admin repo issues on >/dev/null +run_in "$dir" admin repo readonly off >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" admin repo readonly maybe)" +assert_contains "$out" "usage: bgit admin repo readonly on|off" +new_name="$(new_repo_name gcp adminrepo-renamed)" +run_in "$dir" admin repo rename "$new_name" >/dev/null +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$new_name.git" diff --git a/testsuite/gcp/branch_protection.sh b/testsuite/gcp/branch_protection.sh new file mode 100644 index 0000000..e28f2b9 --- /dev/null +++ b/testsuite/gcp/branch_protection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/branch_protection.sh" gcp diff --git a/testsuite/gcp/danger_zone.sh b/testsuite/gcp/danger_zone.sh new file mode 100644 index 0000000..8b09f98 --- /dev/null +++ b/testsuite/gcp/danger_zone.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/danger_zone.sh" gcp diff --git a/testsuite/gcp/identity_selection.sh b/testsuite/gcp/identity_selection.sh new file mode 100644 index 0000000..bda4170 --- /dev/null +++ b/testsuite/gcp/identity_selection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/identity_selection.sh" gcp diff --git a/testsuite/gcp/init.sh b/testsuite/gcp/init.sh new file mode 100755 index 0000000..d264dda --- /dev/null +++ b/testsuite/gcp/init.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp init)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +assert_file_exists "$dir/.git/config" +out="$(git -C "$dir" config --get bucketgit.logicalRepo)" +assert_contains "$out" "$repo" +out="$(git -C "$dir" config --get bucketgit.broker)" +assert_contains "$out" "http" diff --git a/testsuite/gcp/invites_ownership.sh b/testsuite/gcp/invites_ownership.sh new file mode 100644 index 0000000..116cd5d --- /dev/null +++ b/testsuite/gcp/invites_ownership.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/invites_ownership.sh" gcp diff --git a/testsuite/gcp/issues.sh b/testsuite/gcp/issues.sh new file mode 100755 index 0000000..ba15ac2 --- /dev/null +++ b/testsuite/gcp/issues.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp issues)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo issues on >/dev/null +out="$(run_in "$dir" issue create "GCP test issue" --body "created by testsuite")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +assert_contains "$(run_in "$dir" issue list)" "GCP test issue" +run_in "$dir" issue comment "$id" "comment from testsuite" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "comment from testsuite" +run_in "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" diff --git a/testsuite/gcp/issues_permissions.sh b/testsuite/gcp/issues_permissions.sh new file mode 100644 index 0000000..5c64b48 --- /dev/null +++ b/testsuite/gcp/issues_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/issues_permissions.sh" gcp diff --git a/testsuite/gcp/native_git_transport.sh b/testsuite/gcp/native_git_transport.sh new file mode 100644 index 0000000..2f326bf --- /dev/null +++ b/testsuite/gcp/native_git_transport.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/native_git_transport.sh" gcp diff --git a/testsuite/gcp/pr.sh b/testsuite/gcp/pr.sh new file mode 100755 index 0000000..52b18ab --- /dev/null +++ b/testsuite/gcp/pr.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp pr)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp pr" "initial" +run_in "$dir" push -u origin main >/dev/null +run_in "$dir" checkout -b feature/testsuite >/dev/null +commit_file "$dir" FEATURE.md "feature" "feature commit" +run_in "$dir" push -u origin feature/testsuite >/dev/null +out="$(run_in "$dir" pr create --title "GCP testsuite PR" --body "body" --source feature/testsuite --target main)" +assert_contains "$out" "created PR #" +assert_contains "$(run_in "$dir" pr list)" "GCP testsuite PR" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "GCP testsuite PR" +assert_contains "$(run_in "$dir" pr diff "$id")" "FEATURE.md" +out="$(run_in "$dir" pr close "$id")" +assert_contains "$out" "closed" diff --git a/testsuite/gcp/pr_depth.sh b/testsuite/gcp/pr_depth.sh new file mode 100644 index 0000000..235e6fd --- /dev/null +++ b/testsuite/gcp/pr_depth.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/pr_depth.sh" gcp diff --git a/testsuite/gcp/public_private_access.sh b/testsuite/gcp/public_private_access.sh new file mode 100644 index 0000000..4e3fe51 --- /dev/null +++ b/testsuite/gcp/public_private_access.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/public_private_access.sh" gcp diff --git a/testsuite/gcp/push_fetch_pull_lsremote.sh b/testsuite/gcp/push_fetch_pull_lsremote.sh new file mode 100755 index 0000000..ee0e04b --- /dev/null +++ b/testsuite/gcp/push_fetch_pull_lsremote.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp remote)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp remote" "initial" +out="$(run_in "$dir" push -u origin main)" +assert_contains "$out" "main -> main" +out="$(run_in "$dir" ls-remote)" +assert_contains "$out" "refs/heads/main" +clone="$SUITE_ROOT/gcp/repo/remote-clone-$RUN_ID" +rm -rf "$clone" +expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +assert_file_exists "$clone/README.md" +printf 'gcp pull\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "gcp pull source" >/dev/null +run_in "$dir" push >/dev/null +out="$(run_in "$clone" pull)" +assert_contains "$out" "README.md" diff --git a/testsuite/gcp/roles_permissions.sh b/testsuite/gcp/roles_permissions.sh new file mode 100644 index 0000000..9c195eb --- /dev/null +++ b/testsuite/gcp/roles_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/roles_permissions.sh" gcp diff --git a/testsuite/gcp/ssh_key_types.sh b/testsuite/gcp/ssh_key_types.sh new file mode 100755 index 0000000..d3af392 --- /dev/null +++ b/testsuite/gcp/ssh_key_types.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp keytypes)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" + +accept_with_key() { + local label="$1" + local key_path="$2" + local out code + out="$(run_in "$dir" admin invite-user --broker "$broker" --user "$label" --role read "$repo")" + code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" + [[ "$code" == bgitinv_* ]] || fail "invite code not found in output: $out" + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + ssh-add "$key_path" >/dev/null + cd "$dir" + "$BGIT" admin accept-invite "$code" >/dev/null + ) +} + +accept_with_key ed25519 "$SUITE_ROOT/sshkeys/developer" +accept_with_key rsa "$SUITE_ROOT/sshkeys/rsa_owner" +accept_with_key ecdsa "$SUITE_ROOT/sshkeys/ecdsa_owner" + +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "ed25519" +assert_contains "$out" "rsa" +assert_contains "$out" "ecdsa" diff --git a/testsuite/gcp/whoami_repos.sh b/testsuite/gcp/whoami_repos.sh new file mode 100755 index 0000000..e2fac39 --- /dev/null +++ b/testsuite/gcp/whoami_repos.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp whoami)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +out="$(run_in "$dir" whoami)" +assert_contains "$out" "role" +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$repo" diff --git a/testsuite/lib/cases/branch_protection.sh b/testsuite/lib/cases/branch_protection.sh new file mode 100644 index 0000000..fe97fb1 --- /dev/null +++ b/testsuite/lib/cases/branch_protection.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" protection)" +commit_file "$dir" README.md "base" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" admin protect add main >/dev/null +out="$(run_in "$dir" admin protect list)" +assert_contains "$out" "refs/heads/main" +assert_contains "$out" "pr-required" + +printf 'blocked\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "blocked direct push" >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" push)" +assert_contains "$out" "protected branch refs/heads/main requires a pull request" + +run_in "$dir" checkout -b feature/protected >/dev/null +printf 'via pr\n' > "$dir/feature.txt" +run_in "$dir" add feature.txt >/dev/null +run_in "$dir" commit -m "feature protected" >/dev/null +run_in "$dir" push -u origin feature/protected >/dev/null +out="$(run_in "$dir" pr create --title "Protected merge" --source feature/protected --target main)" +assert_contains "$out" "created PR #" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +out="$(run_in_as maintainer "$dir" pr merge "$id")" +assert_contains "$out" "merged PR #$id" + +run_in "$dir" admin protect remove main >/dev/null +out="$(run_in "$dir" admin protect list)" +assert_not_contains "$out" "refs/heads/main" + +run_in "$dir" checkout main >/dev/null +run_in "$dir" pull >/dev/null +run_in "$dir" admin protect add main --allow-owner-admin-override >/dev/null +printf 'owner override\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "owner override" >/dev/null +out="$(run_in "$dir" push --force)" +assert_contains "$out" "main -> main" +run_in "$dir" admin protect remove main >/dev/null + +run_in "$dir" checkout main >/dev/null +printf 'readonly\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "readonly check" >/dev/null +run_in "$dir" admin repo readonly on >/dev/null +out="$(expect_failure_in_as developer "$dir" push)" +assert_contains "$out" "repository is read-only" +run_in "$dir" admin repo readonly off >/dev/null + +run_in "$dir" checkout -b delete/me >/dev/null +printf 'delete\n' > "$dir/delete.txt" +run_in "$dir" add delete.txt >/dev/null +run_in "$dir" commit -m "delete branch" >/dev/null +run_in "$dir" push -u origin delete/me >/dev/null +out="$(run_in "$dir" push --delete delete/me)" +assert_contains "$out" "deleted" +out="$(run_in "$dir" ls-remote --heads)" +assert_not_contains "$out" "refs/heads/delete/me" diff --git a/testsuite/lib/cases/danger_zone.sh b/testsuite/lib/cases/danger_zone.sh new file mode 100644 index 0000000..b10df54 --- /dev/null +++ b/testsuite/lib/cases/danger_zone.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" danger)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +broker="$(git -C "$dir" config --get bucketgit.broker)" +commit_file "$dir" README.md "danger zone" "initial" +run_in "$dir" push -u origin main >/dev/null + +new_name="$(new_repo_name "$provider" danger-renamed)" +out="$(expect_failure_in_as admin "$dir" admin repo rename "$new_name")" +assert_contains "$out" "owner SSH signature required" +out="$(run_in "$dir" admin repo rename "$new_name")" +assert_contains "$out" "renamed repository to $new_name" +renamed_repo="$new_name.git" +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$renamed_repo" +assert_contains "$(git -C "$dir" remote get-url origin)" "$renamed_repo" + +out="$(expect_failure_in_as admin "$dir" admin repo delete --yes)" +assert_contains "$out" "owner SSH signature required" +out="$(cd "$dir" && expect_failure "$BGIT" admin repo delete)" +assert_contains "$out" "usage: bgit admin repo delete --yes" + +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$renamed_repo" +out="$(run_in "$dir" admin repo delete --yes)" +assert_contains "$out" "deleted repository" +out="$(run_in "$dir" repos mine)" +assert_not_contains "$out" "$renamed_repo" +assert_not_contains "$out" "$repo" diff --git a/testsuite/lib/cases/identity_selection.sh b/testsuite/lib/cases/identity_selection.sh new file mode 100644 index 0000000..bd22989 --- /dev/null +++ b/testsuite/lib/cases/identity_selection.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" identity)" +commit_file "$dir" README.md "identity" "initial" +run_in "$dir" push -u origin main >/dev/null + +developer_fp="$(key_fingerprint developer)" +read_fp="$(key_fingerprint read)" +outsider_fp="$(key_fingerprint outsider)" + +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$developer_fp")" +assert_contains "$out" "role: developer" +assert_contains "$out" "selected identity: $developer_fp" + +out="$( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + ssh-add "$(key_path outsider)" >/dev/null + ssh-add "$(key_path developer)" >/dev/null + cd "$dir" + "$BGIT" --identity "$developer_fp" whoami --refresh +)" +assert_contains "$out" "role: developer" + +out="$(expect_failure_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +out="$(with_agent_key read bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$read_fp")" +assert_contains "$out" "role: read" + +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" whoami --json --refresh' _ "$dir" "$BGIT")" +assert_contains "$out" '"role": "developer"' +assert_contains "$out" '"capabilities"' +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" whoami --json' _ "$dir" "$BGIT")" +assert_contains "$out" '"role": "developer"' + +out="$( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + ssh-add "$(key_path developer)" >/dev/null + ssh-add "$(key_path read)" >/dev/null + cd "$dir" + "$BGIT" whoami --all +)" +assert_contains "$out" "developer" +assert_contains "$out" "reader" +assert_contains "$out" "warning:" +out="$( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + ssh-add "$(key_path developer)" >/dev/null + cd "$dir" + "$BGIT" repos mine --json +)" +assert_contains "$out" '"repos"' +assert_contains "$out" '"role": "developer"' + +out="$(with_agent_key outsider bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$outsider_fp" 2>&1 || true)" +assert_contains "$out" "SSH signature required" + diff --git a/testsuite/lib/cases/invites_ownership.sh b/testsuite/lib/cases/invites_ownership.sh new file mode 100644 index 0000000..b594ff1 --- /dev/null +++ b/testsuite/lib/cases/invites_ownership.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" invites-ownership)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" + +out="$(run_in "$dir" admin invite-user --broker "$broker" --user invited-dev --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +invite_code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" +[[ "$invite_code" == bgitinv_* ]] || fail "invite code not found in output: $out" +out="$(expect_failure "$BGIT" admin invite-user --broker "$broker" --user invited-dev --role read "$repo")" +assert_contains "$out" "invite already pending for user" +cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user cancelled-dev --role developer "$repo")" +cancel_code="$(printf '%s\n' "$cancel_out" | awk '/accept-invite/ {print $NF; exit}')" +out="$(run_in "$dir" admin cancel-invite --user cancelled-dev)" +assert_contains "$out" "cancelled invite for cancelled-dev" +out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" +assert_contains "$out" "invite is not pending or has expired" +cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user code-cancelled --role read "$repo")" +cancel_code="$(printf '%s\n' "$cancel_out" | awk '/accept-invite/ {print $NF; exit}')" +out="$(expect_failure "$BGIT" admin cancel-invite "$cancel_code")" +assert_contains "$out" "usage: bgit admin cancel-invite --broker URL --user USER REPO" +out="$(run_in "$dir" admin cancel-invite --user code-cancelled)" +assert_contains "$out" "cancelled invite for code-cancelled" +out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" +assert_contains "$out" "invite is not pending or has expired" +out="$(SSH_AUTH_SOCK= expect_failure "$BGIT" admin accept-invite "$invite_code")" +assert_contains "$out" "SSH signature required" +out="$(with_agent_key outsider "$BGIT" admin accept-invite "$invite_code")" +assert_contains "$out" "accepted invite for invited-dev as developer" +out="$(expect_failure_in_as outsider "$dir" admin accept-invite "$invite_code")" +assert_contains "$out" "invite is not pending or has expired" +out="$(run_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "user: invited-dev" +assert_contains "$out" "role: developer" + +out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "ownership transfer pending" +transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" +[[ "$transfer_code" == bgitot_* ]] || fail "ownership transfer code not found in output: $out" +out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "ownership transfer already pending" +out="$(run_in "$dir" admin cancel-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "cancelled pending ownership transfer" +out="$(with_agent_key outsider expect_failure "$BGIT" admin accept-ownership-transfer "$transfer_code")" +assert_contains "$out" "ownership transfer is not pending or has expired" + +out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" +transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" +out="$(with_agent_key outsider "$BGIT" admin accept-ownership-transfer "$transfer_code")" +assert_contains "$out" "accepted ownership for $repo" +out="$(run_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "role: owner" +out="$(run_in "$dir" whoami --refresh)" +assert_contains "$out" "role: admin" +out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "owner SSH signature required" diff --git a/testsuite/lib/cases/issues_permissions.sh b/testsuite/lib/cases/issues_permissions.sh new file mode 100644 index 0000000..d43094d --- /dev/null +++ b/testsuite/lib/cases/issues_permissions.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" issue-perms)" +commit_file "$dir" README.md "issues" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" admin repo issues off >/dev/null +out="$(expect_failure_in_as triage "$dir" issue create "disabled")" +assert_contains "$out" "issues are disabled" +run_in "$dir" admin repo issues on >/dev/null + +out="$(expect_failure_in_as outsider "$dir" issue create "private outsider")" +assert_contains "$out" "read SSH signature required" +out="$(run_in_as read "$dir" issue create "read issue" --body "private member")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +run_in_as triage "$dir" issue comment "$id" "triage comment" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "triage comment" +out="$(expect_failure_in_as read "$dir" issue close "$id")" +assert_contains "$out" "write SSH signature required" +run_in_as developer "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in_as developer "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" +out="$(expect_failure_in_as developer "$dir" issue view 99999)" +assert_contains "$out" "issue not found" + +run_in "$dir" admin repo visibility public >/dev/null +out="$(run_in_no_agent "$dir" issue create "anonymous public issue" --body "anon")" +assert_contains "$out" "created issue #" +anon_id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +run_in_no_agent "$dir" issue comment "$anon_id" "anonymous comment" >/dev/null +assert_contains "$(run_in "$dir" issue view "$anon_id")" "anonymous comment" + diff --git a/testsuite/lib/cases/local_more_porcelain.sh b/testsuite/lib/cases/local_more_porcelain.sh new file mode 100644 index 0000000..8a60ca7 --- /dev/null +++ b/testsuite/lib/cases/local_more_porcelain.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../testlib.sh" + +dir="$(new_workdir local porcelain-more)" +"$BGIT" init --noninteractive --repo local-porcelain-more --profile "$GCP_PROFILE" "${CONFIG_ARGS[@]}" "$dir" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md "line one" "initial" + +run_in "$dir" switch -c feature >/dev/null +printf 'feature\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "feature change" >/dev/null +run_in "$dir" switch main >/dev/null +run_in "$dir" cherry-pick feature >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "feature change" +run_in "$dir" revert HEAD >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "Revert" + +printf 'stash\n' >> "$dir/README.md" +run_in "$dir" stash push -m "stash test" >/dev/null +assert_contains "$(run_in "$dir" stash list)" "stash@{0}" +run_in "$dir" stash pop >/dev/null 2>&1 || true +assert_contains "$(run_in "$dir" status)" "modified:" +run_in "$dir" reset --hard HEAD >/dev/null + +mkdir -p "$dir/tmp" +printf 'tmp\n' > "$dir/tmp/generated.txt" +run_in "$dir" clean -f -d >/dev/null +assert_file_not_exists "$dir/tmp/generated.txt" + +run_in "$dir" tag v-local >/dev/null +assert_contains "$(run_in "$dir" describe)" "v-local" +run_in "$dir" tag -v v-local >/dev/null 2>&1 || true +run_in "$dir" tag -d v-local >/dev/null +assert_not_contains "$(run_in "$dir" tag)" "v-local" + +assert_contains "$(run_in "$dir" blame README.md)" "line one" +assert_contains "$(run_in "$dir" ls-tree HEAD)" "README.md" +run_in "$dir" archive --format=tar HEAD >/tmp/bgit-archive-$$.tar +test -s /tmp/bgit-archive-$$.tar || fail "archive output was empty" +rm -f /tmp/bgit-archive-$$.tar +run_in "$dir" config bucketgit.test value >/dev/null +assert_contains "$(run_in "$dir" config --get bucketgit.test)" "value" + +run_in "$dir" branch delete-test >/dev/null +run_in "$dir" branch -d delete-test >/dev/null +assert_not_contains "$(run_in "$dir" branch)" "delete-test" + +printf 'unsafe main\n' >> "$dir/README.md" +out="$(cd "$dir" && expect_failure "$BGIT" clean --bad-option)" +assert_contains "$out" "unsupported clean option" diff --git a/testsuite/lib/cases/native_git_transport.sh b/testsuite/lib/cases/native_git_transport.sh new file mode 100644 index 0000000..bead359 --- /dev/null +++ b/testsuite/lib/cases/native_git_transport.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" native)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +commit_file "$dir" README.md "native" "initial" +(cd "$dir" && git push -u origin main >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/main" + +clone="$SUITE_ROOT/$provider/repo/native-clone-$RUN_ID" +rm -rf "$clone" +"$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$repo" "$clone" >/dev/null +init_local_git_identity "$clone" +assert_file_exists "$clone/README.md" +(cd "$clone" && git fetch origin >/dev/null) +(cd "$clone" && git ls-remote origin >/tmp/bgit-native-lsremote.$$) +assert_contains "$(cat /tmp/bgit-native-lsremote.$$)" "refs/heads/main" +rm -f /tmp/bgit-native-lsremote.$$ + +(cd "$clone" && git checkout -b native/feature >/dev/null) +printf 'feature\n' > "$clone/native.txt" +(cd "$clone" && git add native.txt && git commit -m "native feature" >/dev/null && git push -u origin native/feature >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/native/feature" +branch_remote="$(git -C "$clone" config --get branch.native/feature.remote)" +assert_contains "$branch_remote" "origin" + +(cd "$clone" && git tag native-v1 && git push origin native-v1 >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --tags)" "refs/tags/native-v1" + +(cd "$clone" && git push origin --delete native/feature >/dev/null) +assert_not_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/native/feature" +if git -C "$clone" config --get branch.native/feature.remote >/dev/null 2>&1; then + fail "branch tracking should be removed after native branch delete" +fi + +printf 'remote update\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "remote update" >/dev/null +run_in "$dir" push >/dev/null +(cd "$clone" && git checkout main >/dev/null && git pull >/dev/null) +assert_contains "$(cat "$clone/README.md")" "remote update" + diff --git a/testsuite/lib/cases/pr_depth.sh b/testsuite/lib/cases/pr_depth.sh new file mode 100644 index 0000000..0eaabf9 --- /dev/null +++ b/testsuite/lib/cases/pr_depth.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" pr-depth)" +commit_file "$dir" README.md "base" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" checkout -b feature/pr-depth >/dev/null +printf 'change\n' >> "$dir/README.md" +printf 'new\n' > "$dir/NEW.md" +run_in "$dir" add README.md NEW.md >/dev/null +run_in "$dir" commit -m "pr depth change" >/dev/null +run_in "$dir" push -u origin feature/pr-depth >/dev/null +out="$(run_in_as developer "$dir" pr create --title "PR depth" --body "body" --source feature/pr-depth --target main)" +assert_contains "$out" "created PR #" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "status: open" +assert_contains "$(run_in "$dir" pr diff "$id")" "NEW.md" + +run_in "$dir" pr close "$id" >/dev/null +assert_contains "$(run_in "$dir" pr view "$id")" "status: closed" +run_in_as maintainer "$dir" pr reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" pr view "$id")" "status: open" + +out="$(expect_failure_in_as triage "$dir" pr approve "$id" "looks good")" +assert_contains "$out" "write SSH signature required" +run_in_as triage "$dir" pr comment "$id" "triage comment" >/dev/null +run_in_as maintainer "$dir" pr approve "$id" "looks good" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "approvals: 1" +run_in_as developer "$dir" pr reject "$id" "needs work" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "approvals: 1" +run_in_as read "$dir" pr comment "$id" "reader comment" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "PR depth" + +out="$(run_in_as maintainer "$dir" pr merge "$id" --delete-branch)" +assert_contains "$out" "merged PR #$id" +assert_contains "$(run_in "$dir" pr view "$id")" "status: merged" +out="$(run_in "$dir" ls-remote --heads)" +assert_not_contains "$out" "refs/heads/feature/pr-depth" +out="$(expect_failure_in_as maintainer "$dir" pr merge "$id")" +assert_contains "$out" "pull request is not open" diff --git a/testsuite/lib/cases/public_private_access.sh b/testsuite/lib/cases/public_private_access.sh new file mode 100644 index 0000000..aa6b5ff --- /dev/null +++ b/testsuite/lib/cases/public_private_access.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" public-private)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +clone_url="${broker%/}/$repo" +commit_file "$dir" README.md "public private access" "initial" +run_in "$dir" push -u origin main >/dev/null + +private_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-private-$RUN_ID" +private_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-private-$RUN_ID" +out="$(SSH_AUTH_SOCK= expect_failure "$BGIT" clone "$clone_url" "$private_no_key")" +assert_contains "$out" "broker denied read access" +out="$(with_agent_key outsider expect_failure "$BGIT" clone "$clone_url" "$private_unknown")" +assert_contains "$out" "broker denied read access" + +run_in "$dir" admin repo visibility public >/dev/null + +public_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-public-$RUN_ID" +public_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-public-$RUN_ID" +SSH_AUTH_SOCK= expect_success "$BGIT" clone "$clone_url" "$public_no_key" >/dev/null +assert_file_exists "$public_no_key/README.md" +assert_contains "$(cat "$public_no_key/README.md")" "public private access" +with_agent_key outsider expect_success "$BGIT" clone "$clone_url" "$public_unknown" >/dev/null +assert_file_exists "$public_unknown/README.md" +assert_contains "$(cat "$public_unknown/README.md")" "public private access" + +run_in "$dir" admin repo visibility private >/dev/null +out="$(cd "$public_no_key" && SSH_AUTH_SOCK= expect_failure "$BGIT" ls-remote)" +assert_contains "$out" "read SSH signature required" diff --git a/testsuite/lib/cases/roles_permissions.sh b/testsuite/lib/cases/roles_permissions.sh new file mode 100644 index 0000000..dffd967 --- /dev/null +++ b/testsuite/lib/cases/roles_permissions.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" roles)" +commit_file "$dir" README.md "roles" "initial" +run_in "$dir" push -u origin main >/dev/null + +out="$(run_in_as read "$dir" whoami --refresh)" +assert_contains "$out" "role: read" +out="$(expect_failure_in_as read "$dir" push)" +assert_contains "$out" "write SSH signature required" + +out="$(run_in_as triage "$dir" issue create "triage can create issue" --body "triage")" +assert_contains "$out" "created issue #" +out="$(expect_failure_in_as triage "$dir" pr create --title "triage cannot pr" --source main --target main)" +assert_contains "$out" "write SSH signature required" + +run_in_as developer "$dir" checkout -b developer/branch >/dev/null +printf 'developer\n' > "$dir/developer.txt" +run_in_as developer "$dir" add developer.txt >/dev/null +run_in_as developer "$dir" commit -m "developer branch" >/dev/null +out="$(run_in_as developer "$dir" push -u origin developer/branch)" +assert_contains "$out" "developer/branch -> developer/branch" + +out="$(expect_failure_in_as maintainer "$dir" admin keys list)" +assert_contains "$out" "admin SSH signature required" +out="$(run_in_as admin "$dir" admin keys list)" +assert_contains "$out" "developer" + +owner_fp="$(key_fingerprint owner)" +out="$(expect_failure_in_as admin "$dir" admin keys remove "$owner_fp")" +assert_contains "$out" "owners cannot be removed or suspended" +out="$(expect_failure_in_as admin "$dir" admin keys suspend "$owner_fp")" +assert_contains "$out" "owners cannot be removed or suspended" + +developer_fp="$(key_fingerprint developer)" +run_in_as admin "$dir" admin keys suspend "$developer_fp" >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +run_in_as admin "$dir" admin keys remove "$developer_fp" >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + +out="$(expect_failure_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh new file mode 100755 index 0000000..09a8914 --- /dev/null +++ b/testsuite/lib/testlib.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BGIT="${BGIT:-$ROOT/bgit}" +SUITE_ROOT="$ROOT/testsuite" +RUN_ID="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" +GCP_PROFILE="${BGIT_TEST_GCP_PROFILE:-gcp:local/test}" +AWS_PROFILE="${BGIT_TEST_AWS_PROFILE:-aws:local/test}" +CONFIG_ARGS=() +if [[ -n "${BGIT_TEST_CONFIG:-}" ]]; then + CONFIG_ARGS=(--config "$BGIT_TEST_CONFIG") +fi + +log() { printf '[%s] %s\n' "$(basename "$0")" "$*" >&2; } +fail() { printf '[%s] FAIL: %s\n' "$(basename "$0")" "$*" >&2; exit 1; } + +assert_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" == *"$needle"* ]] || fail "expected output to contain '$needle'; got: $haystack" +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" != *"$needle"* ]] || fail "expected output not to contain '$needle'; got: $haystack" +} + +assert_file_exists() { + [[ -e "$1" ]] || fail "expected file to exist: $1" +} + +assert_file_not_exists() { + [[ ! -e "$1" ]] || fail "expected file not to exist: $1" +} + +expect_success() { + local out + if ! out="$("$@" 2>&1)"; then + printf '%s\n' "$out" >&2 + fail "command failed: $*" + fi + printf '%s' "$out" +} + +expect_failure() { + local out + if out="$("$@" 2>&1)"; then + printf '%s\n' "$out" >&2 + fail "command unexpectedly succeeded: $*" + fi + printf '%s' "$out" +} + +provider_profile() { + case "$1" in + gcp) printf '%s' "$GCP_PROFILE" ;; + aws) printf '%s' "$AWS_PROFILE" ;; + *) fail "unknown provider $1" ;; + esac +} + +provider_dir() { + printf '%s/%s/repo' "$SUITE_ROOT" "$1" +} + +new_repo_name() { + printf 'bgit-it-%s-%s-%s' "$1" "$RUN_ID" "${2:-repo}" +} + +new_workdir() { + local provider="$1" + local name="$2" + local dir + dir="$(provider_dir "$provider")/$name" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +init_local_git_identity() { + git -C "$1" config user.name "BucketGit Tests" + git -C "$1" config user.email "tests@bucketgit.local" +} + +init_bgit_repo() { + local provider="$1" + local suffix="$2" + local profile repo dir + profile="$(provider_profile "$provider")" + repo="$(new_repo_name "$provider" "$suffix")" + dir="$(new_workdir "$provider" "$suffix")" + expect_success "$BGIT" init --noninteractive --repo "$repo" --profile "$profile" "${CONFIG_ARGS[@]}" "$dir" >/dev/null + init_local_git_identity "$dir" + printf '%s\n%s\n' "$dir" "$repo.git" +} + +commit_file() { + local dir="$1" + local file="$2" + local body="$3" + local msg="$4" + printf '%s\n' "$body" > "$dir/$file" + (cd "$dir" && "$BGIT" add "$file" && "$BGIT" commit -m "$msg" >/dev/null) +} + +run_in() { + local dir="$1" + shift + (cd "$dir" && "$BGIT" "$@") +} + +key_path() { + printf '%s/sshkeys/%s' "$SUITE_ROOT" "$1" +} + +key_fingerprint() { + ssh-keygen -lf "$(key_path "$1.pub")" | awk '{print $2}' +} + +with_agent_key() { + local key="$1" + shift + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + chmod 600 "$(key_path "$key")" >/dev/null 2>&1 || true + ssh-add "$(key_path "$key")" >/dev/null + "$@" + ) +} + +run_in_as() { + local key="$1" + local dir="$2" + shift 2 + with_agent_key "$key" bash -c 'cd "$1" && shift && "$@"' _ "$dir" "$BGIT" "$@" +} + +run_in_no_agent() { + local dir="$1" + shift + (cd "$dir" && SSH_AUTH_SOCK= "$BGIT" "$@") +} + +expect_failure_no_agent() { + local dir="$1" + shift + (cd "$dir" && SSH_AUTH_SOCK= expect_failure "$BGIT" "$@") +} + +expect_failure_in_as() { + local key="$1" + local dir="$2" + shift 2 + with_agent_key "$key" bash -c ' + dir="$1" + shift + cd "$dir" + if out="$("$@" 2>&1)"; then + printf "%s\n" "$out" >&2 + exit 99 + fi + printf "%s" "$out" + ' _ "$dir" "$BGIT" "$@" +} + +add_key_to_repo() { + local dir="$1" + local user="$2" + local role="$3" + local key="$4" + run_in "$dir" admin keys add --no-agent --key "$(key_path "$key.pub")" --user "$user" --role "$role" >/dev/null +} + +setup_role_repo() { + local provider="$1" + local suffix="$2" + local init_output dir + init_output="$(init_bgit_repo "$provider" "$suffix")" + dir="$(printf "%s\n" "$init_output" | sed -n "1p")" + add_key_to_repo "$dir" admin admin admin + add_key_to_repo "$dir" maintainer maintainer maintainer + add_key_to_repo "$dir" developer developer developer + add_key_to_repo "$dir" triage triage triage + add_key_to_repo "$dir" reader read read + printf '%s\n' "$dir" +} diff --git a/testsuite/local/add.sh b/testsuite/local/add.sh new file mode 100755 index 0000000..83191cd --- /dev/null +++ b/testsuite/local/add.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local add)" +"$BGIT" init --noninteractive --repo local-add "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +printf 'hello\n' > "$dir/README.md" +out="$(run_in "$dir" status)" +assert_contains "$out" "Untracked files" +run_in "$dir" add README.md >/dev/null +out="$(run_in "$dir" status)" +assert_contains "$out" "Changes to be committed" diff --git a/testsuite/local/branch_checkout_merge.sh b/testsuite/local/branch_checkout_merge.sh new file mode 100755 index 0000000..9da7cff --- /dev/null +++ b/testsuite/local/branch_checkout_merge.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local branch)" +"$BGIT" init --noninteractive --repo local-branch "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md base "base" +run_in "$dir" checkout -b feature >/dev/null +commit_file "$dir" feature.txt feature "feature" +run_in "$dir" checkout main >/dev/null +out="$(run_in "$dir" merge feature)" +assert_contains "$out" "feature.txt" diff --git a/testsuite/local/commit.sh b/testsuite/local/commit.sh new file mode 100755 index 0000000..4d858bd --- /dev/null +++ b/testsuite/local/commit.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local commit)" +"$BGIT" init --noninteractive --repo local-commit "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +printf 'commit\n' > "$dir/README.md" +run_in "$dir" add README.md >/dev/null +out="$(run_in "$dir" commit -m "local commit")" +assert_contains "$out" "local commit" +out="$(run_in "$dir" log --oneline)" +assert_contains "$out" "local commit" diff --git a/testsuite/local/diff_log_show_status.sh b/testsuite/local/diff_log_show_status.sh new file mode 100755 index 0000000..cc1ea61 --- /dev/null +++ b/testsuite/local/diff_log_show_status.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local inspect)" +"$BGIT" init --noninteractive --repo local-inspect "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md one "one" +printf 'two\n' >> "$dir/README.md" +out="$(run_in "$dir" diff)" +assert_contains "$out" "+two" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m two >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "two" +assert_contains "$(run_in "$dir" show HEAD)" "two" +assert_contains "$(run_in "$dir" status)" "working tree clean" diff --git a/testsuite/local/more_porcelain.sh b/testsuite/local/more_porcelain.sh new file mode 100644 index 0000000..ef9b0a7 --- /dev/null +++ b/testsuite/local/more_porcelain.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/local_more_porcelain.sh" diff --git a/testsuite/local/porcelain_misc.sh b/testsuite/local/porcelain_misc.sh new file mode 100755 index 0000000..474fd6e --- /dev/null +++ b/testsuite/local/porcelain_misc.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local porcelain)" +"$BGIT" init --noninteractive --repo local-porcelain "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md "alpha" "initial" +run_in "$dir" tag v1 >/dev/null +assert_contains "$(run_in "$dir" tag)" "v1" +assert_contains "$(run_in "$dir" grep alpha)" "README.md" +run_in "$dir" mv README.md MOVED.md >/dev/null +assert_contains "$(run_in "$dir" status)" "renamed:" +run_in "$dir" restore --staged MOVED.md >/dev/null || true +run_in "$dir" reset --hard HEAD >/dev/null +assert_file_exists "$dir/README.md" +run_in "$dir" rm README.md >/dev/null +assert_contains "$(run_in "$dir" status)" "deleted:" +run_in "$dir" reset --hard HEAD >/dev/null +assert_file_exists "$dir/README.md" +head_hash="$(run_in "$dir" rev-parse HEAD)" +[[ "${#head_hash}" -ge 40 ]] || fail "expected rev-parse HEAD to return a commit hash" +assert_contains "$(run_in "$dir" ls-files)" "README.md" diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh new file mode 100755 index 0000000..3e61426 --- /dev/null +++ b/testsuite/run-local-broker.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +runtime="${1:-${BGIT_TEST_BROKER_RUNTIME:-gcp}}" +case "$runtime" in + gcp) provider="gcp"; port="${BGIT_TEST_BROKER_PORT:-19190}" ;; + aws) provider="aws"; port="${BGIT_TEST_BROKER_PORT:-19191}" ;; + *) printf 'usage: ./testsuite/run-local-broker.sh gcp|aws\n' >&2; exit 2 ;; +esac + +export GOCACHE="${GOCACHE:-$(go env GOCACHE 2>/dev/null || printf '/tmp/bgit-gocache')}" +export GOMODCACHE="${GOMODCACHE:-$(go env GOMODCACHE 2>/dev/null || printf '/tmp/bgit-gomodcache')}" +if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then + go build -o bgit . +fi + +run_id="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" +tmp_root="${TMPDIR:-${TMP:-/tmp}}" +test_root="${BGIT_TEST_LOCAL_BROKER_ROOT:-${tmp_root%/}/bgit-local-broker-${runtime}-${run_id}}" +broker_url="http://127.0.0.1:${port}" +config_path="${test_root}/home/.bgit/config.yaml" +mkdir -p "$(dirname "$config_path")" +export HOME="${test_root}/home" + +cat > "$config_path" < "${test_root}/broker.log" 2>&1 & +broker_pid=$! +status=0 +cleanup() { + status=$? + kill "$broker_pid" >/dev/null 2>&1 || true + if [[ -n "${SSH_AGENT_PID:-}" ]]; then ssh-agent -k >/dev/null 2>&1 || true; fi + if [[ "$status" -eq 0 && "${BGIT_TEST_KEEP_ARTIFACTS:-}" != "1" ]]; then + rm -rf "$test_root" + rm -rf "$ROOT/testsuite/local/repo" + rm -rf "$ROOT/testsuite/${provider}/repo" + else + printf 'kept test artifacts in %s and %s\n' "$test_root" "$ROOT/testsuite/${provider}/repo" >&2 + fi + exit "$status" +} +trap cleanup EXIT + +for _ in $(seq 1 100); do + if curl -sS "${broker_url}/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +curl -sS "${broker_url}/health" >/dev/null + +eval "$(ssh-agent -s)" >/dev/null +chmod 600 "$ROOT/testsuite/sshkeys/owner" >/dev/null 2>&1 || true +ssh-add "$ROOT/testsuite/sshkeys/owner" >/dev/null + +owner_key="$(cat "$ROOT/testsuite/sshkeys/owner.pub")" +curl -sS -X POST "${broker_url}/owners/upsert" \ + -H 'content-type: application/json' \ + --data "{\"user\":\"owner\",\"role\":\"owner\",\"public_keys\":[\"${owner_key}\"]}" >/dev/null + +export BGIT="${BGIT:-$ROOT/bgit}" +export BGIT_TEST_USE_EXISTING_BINARY=1 +export BGIT_TEST_RUN_ID="$run_id" +export BGIT_TEST_PROVIDER="$provider" +export BGIT_TEST_CONFIG="$config_path" +export BGIT_TEST_GCP_PROFILE="gcp:local/test" +export BGIT_TEST_AWS_PROFILE="aws:local/test" +export BGIT_TEST_IN_LOCAL_BROKER=1 + +printf 'Running %s tests against local %s broker at %s\n' "$provider" "$runtime" "$broker_url" +"$ROOT/testsuite/run.sh" diff --git a/testsuite/run.sh b/testsuite/run.sh new file mode 100755 index 0000000..e22dc00 --- /dev/null +++ b/testsuite/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ "${BGIT_TEST_IN_LOCAL_BROKER:-}" != "1" ]]; then + runtimes=() + provider_filter="${BGIT_TEST_PROVIDER:-all}" + if [[ "$provider_filter" == "all" || "$provider_filter" == "gcp" ]]; then + runtimes+=(gcp) + fi + if [[ "$provider_filter" == "all" || "$provider_filter" == "aws" ]]; then + runtimes+=(aws) + fi + for runtime in "${runtimes[@]}"; do + "$ROOT/testsuite/run-local-broker.sh" "$runtime" + done + exit 0 +fi + +export GOCACHE="${GOCACHE:-$(go env GOCACHE 2>/dev/null || printf '/tmp/bgit-gocache')}" +if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then + go build -o bgit . +fi + +export BGIT="${BGIT:-$ROOT/bgit}" +export BGIT_TEST_RUN_ID="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" + +provider_filter="${BGIT_TEST_PROVIDER:-all}" + +tests=() +while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/local -name '*.sh' -type f -print0 | sort -z) +if [[ "$provider_filter" == "all" || "$provider_filter" == "gcp" ]]; then + while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/gcp -maxdepth 1 -name '*.sh' -type f -print0 | sort -z) +fi +if [[ "$provider_filter" == "all" || "$provider_filter" == "aws" ]]; then + while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/aws -maxdepth 1 -name '*.sh' -type f -print0 | sort -z) +fi + +printf 'Running %d integration test files with run id %s\n' "${#tests[@]}" "$BGIT_TEST_RUN_ID" +for test in "${tests[@]}"; do + printf '\n==> %s\n' "$test" + bash "$test" +done + +printf '\nIntegration suite passed.\n' diff --git a/testsuite/sshkeys/admin b/testsuite/sshkeys/admin new file mode 100644 index 0000000..a04293c --- /dev/null +++ b/testsuite/sshkeys/admin @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACC5JidYWiSlyNsgB2w1r7+7CuRGbRDeC8fqNT92tLfjOQAAAJjx7md+8e5n +fgAAAAtzc2gtZWQyNTUxOQAAACC5JidYWiSlyNsgB2w1r7+7CuRGbRDeC8fqNT92tLfjOQ +AAAEBZqOWhvPb5qwYIRW2pqJ/wgBCB8IQeS31gRz0J4OAhn7kmJ1haJKXI2yAHbDWvv7sK +5EZtEN4Lx+o1P3a0t+M5AAAAFGJnaXQtdGVzdHN1aXRlLWFkbWluAQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/admin.pub b/testsuite/sshkeys/admin.pub new file mode 100644 index 0000000..fe39942 --- /dev/null +++ b/testsuite/sshkeys/admin.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILkmJ1haJKXI2yAHbDWvv7sK5EZtEN4Lx+o1P3a0t+M5 bgit-testsuite-admin diff --git a/testsuite/sshkeys/developer b/testsuite/sshkeys/developer new file mode 100644 index 0000000..666cdae --- /dev/null +++ b/testsuite/sshkeys/developer @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAH5LlS5hY9FWPmkr98BqLD9iyaTVidooyn+Hin5XulqQAAAKCCkMC0gpDA +tAAAAAtzc2gtZWQyNTUxOQAAACAH5LlS5hY9FWPmkr98BqLD9iyaTVidooyn+Hin5XulqQ +AAAEBMce9totBr4zRh3tzZtJM+QSn2tQjTQ3XgTobQnCtQJgfkuVLmFj0VY+aSv3wGosP2 +LJpNWJ2ijKf4eKfle6WpAAAAGGJnaXQtdGVzdHN1aXRlLWRldmVsb3BlcgECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/developer.pub b/testsuite/sshkeys/developer.pub new file mode 100644 index 0000000..3f0aebc --- /dev/null +++ b/testsuite/sshkeys/developer.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfkuVLmFj0VY+aSv3wGosP2LJpNWJ2ijKf4eKfle6Wp bgit-testsuite-developer diff --git a/testsuite/sshkeys/ecdsa_owner b/testsuite/sshkeys/ecdsa_owner new file mode 100644 index 0000000..2eff7da --- /dev/null +++ b/testsuite/sshkeys/ecdsa_owner @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSGwLYxWggAehWvG5Z0iquQBlCkWnv6 +mjFy+dK+rMcWNIir4DIsDQY5R7hivdqdZA37SNajoqTyawwJwNVIxRkNAAAAuIFXKAqBVy +gKAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbAtjFaCAB6Fa8b +lnSKq5AGUKRae/qaMXL50r6sxxY0iKvgMiwNBjlHuGK92p1kDftI1qOipPJrDAnA1UjFGQ +0AAAAhAJlRPyByzdqxiUqLJuG5ZFk6ZK7siL4cxreqmnoYZMmHAAAAGmJnaXQtdGVzdHN1 +aXRlLWVjZHNhX293bmVyAQIDBAU= +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/ecdsa_owner.pub b/testsuite/sshkeys/ecdsa_owner.pub new file mode 100644 index 0000000..cc0cdfe --- /dev/null +++ b/testsuite/sshkeys/ecdsa_owner.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbAtjFaCAB6Fa8blnSKq5AGUKRae/qaMXL50r6sxxY0iKvgMiwNBjlHuGK92p1kDftI1qOipPJrDAnA1UjFGQ0= bgit-testsuite-ecdsa_owner diff --git a/testsuite/sshkeys/maintainer b/testsuite/sshkeys/maintainer new file mode 100644 index 0000000..ed0beb5 --- /dev/null +++ b/testsuite/sshkeys/maintainer @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCzq5ZU79gbaD6GeQqcGqZ+3TJQr/NLz9n9IvBhfxVY3wAAAKCiV/9Wolf/ +VgAAAAtzc2gtZWQyNTUxOQAAACCzq5ZU79gbaD6GeQqcGqZ+3TJQr/NLz9n9IvBhfxVY3w +AAAEDWu9fXA06Gvr+WJpR3cm/RYNxns+Zaid4gHvwr2SrwBbOrllTv2BtoPoZ5Cpwapn7d +MlCv80vP2f0i8GF/FVjfAAAAGWJnaXQtdGVzdHN1aXRlLW1haW50YWluZXIBAgME +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/maintainer.pub b/testsuite/sshkeys/maintainer.pub new file mode 100644 index 0000000..eff86ec --- /dev/null +++ b/testsuite/sshkeys/maintainer.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILOrllTv2BtoPoZ5Cpwapn7dMlCv80vP2f0i8GF/FVjf bgit-testsuite-maintainer diff --git a/testsuite/sshkeys/outsider b/testsuite/sshkeys/outsider new file mode 100644 index 0000000..9746cc7 --- /dev/null +++ b/testsuite/sshkeys/outsider @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAEi3xa43hNEaEnxsu9TIcnnIrJGdUFnZPIDo2wlldt5AAAAKBlmmfNZZpn +zQAAAAtzc2gtZWQyNTUxOQAAACAEi3xa43hNEaEnxsu9TIcnnIrJGdUFnZPIDo2wlldt5A +AAAECHNddXcQfY0vVX2BVv1QvvAZ79hkTGiCqb8zbwYBcFAwSLfFrjeE0RoSfGy71Mhyec +iskZ1QWdk8gOjbCWV23kAAAAF2JnaXQtdGVzdHN1aXRlLW91dHNpZGVyAQIDBAUG +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/outsider.pub b/testsuite/sshkeys/outsider.pub new file mode 100644 index 0000000..7e3dfef --- /dev/null +++ b/testsuite/sshkeys/outsider.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIASLfFrjeE0RoSfGy71MhyeciskZ1QWdk8gOjbCWV23k bgit-testsuite-outsider diff --git a/testsuite/sshkeys/owner b/testsuite/sshkeys/owner new file mode 100644 index 0000000..f0d0158 --- /dev/null +++ b/testsuite/sshkeys/owner @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACD9k6ppi/81E9PbJ4ybh7H8toOwJb1+s3bRI1jHoPMhpQAAAJjL6sLpy+rC +6QAAAAtzc2gtZWQyNTUxOQAAACD9k6ppi/81E9PbJ4ybh7H8toOwJb1+s3bRI1jHoPMhpQ +AAAEBLtQMZ5r06BZTgZT39nmpdRb9KK4QSaQfupy2OMMnpp/2TqmmL/zUT09snjJuHsfy2 +g7AlvX6zdtEjWMeg8yGlAAAAFGJnaXQtdGVzdHN1aXRlLW93bmVyAQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/owner.pub b/testsuite/sshkeys/owner.pub new file mode 100644 index 0000000..5bfb281 --- /dev/null +++ b/testsuite/sshkeys/owner.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP2TqmmL/zUT09snjJuHsfy2g7AlvX6zdtEjWMeg8yGl bgit-testsuite-owner diff --git a/testsuite/sshkeys/read b/testsuite/sshkeys/read new file mode 100644 index 0000000..460b485 --- /dev/null +++ b/testsuite/sshkeys/read @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAIb+Yr0s1MAi4fiB+vCn6rRkfONxsGQbHDZ88SBpcwpwAAAJhTQ7BBU0Ow +QQAAAAtzc2gtZWQyNTUxOQAAACAIb+Yr0s1MAi4fiB+vCn6rRkfONxsGQbHDZ88SBpcwpw +AAAEDvi5R9NXyRt+Xl/wnn2lAY9jYAgQJQUZBZy4pUPw6s/whv5ivSzUwCLh+IH68KfqtG +R843GwZBscNnzxIGlzCnAAAAE2JnaXQtdGVzdHN1aXRlLXJlYWQBAg== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/read.pub b/testsuite/sshkeys/read.pub new file mode 100644 index 0000000..ffa349c --- /dev/null +++ b/testsuite/sshkeys/read.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAhv5ivSzUwCLh+IH68KfqtGR843GwZBscNnzxIGlzCn bgit-testsuite-read diff --git a/testsuite/sshkeys/rsa_owner b/testsuite/sshkeys/rsa_owner new file mode 100644 index 0000000..34d4f29 --- /dev/null +++ b/testsuite/sshkeys/rsa_owner @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/rsa_owner.pub b/testsuite/sshkeys/rsa_owner.pub new file mode 100644 index 0000000..f4343e2 --- /dev/null +++ b/testsuite/sshkeys/rsa_owner.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDEQVEEN9G7b/qtBrX7+UfJ1YFMhUXmq1lDX4H2NJugnin+eFRk8gGBmSTteVupKvCNRFDuqkPCYNjxszNQISStw214ACSl9hEaPHLxV4LMDCR+EQA6qDug6HMBvV9B+1JVudrZha6543KS5bQ/bDpS2objR0kEhPp5ODZRT6Bv6zDhsK9lUpGV/cnMXhuhJnMKANj+nFja1UQixOVulYFsrjB9hQ8LLFTljWBevD7/nKKWd9nOzI6pYiaTf4kNEN2V/6OOZEU+2q1vGr9gB7tMU+ec18lvVD/bpolxmoxvkXT2PdGOeSpeNxDow7NhfxxjdX60KxyYOWZ6NYxsG5gMGDSIJq/UgBBlKojSbQavAJ/NdXNJGuDQdZ1Rsco+akPp/ISvb7Az0y/BCh8EmPBm/VqlJ85qfJK9PFqJ9QT8w00kFxI6HaD06rdJxQQIsIkQGaZETTDsF1/Ar7XYEfGO3B+3ueHhXdFKhmCn7S1NUQ0Z15CbHKax2fiW/cBaNc0= bgit-testsuite-rsa_owner diff --git a/testsuite/sshkeys/triage b/testsuite/sshkeys/triage new file mode 100644 index 0000000..1ed3a16 --- /dev/null +++ b/testsuite/sshkeys/triage @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA9+bBAsCVJS3WSHtgoagQvrk1XR3RIB21Xnl/Yd7ZgVQAAAJihDDsloQw7 +JQAAAAtzc2gtZWQyNTUxOQAAACA9+bBAsCVJS3WSHtgoagQvrk1XR3RIB21Xnl/Yd7ZgVQ +AAAECRMmBUVK+99J8r6wNgkf2DoBb/FX5Gzeyz0y48INPR+j35sECwJUlLdZIe2ChqBC+u +TVdHdEgHbVeeX9h3tmBVAAAAFWJnaXQtdGVzdHN1aXRlLXRyaWFnZQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/triage.pub b/testsuite/sshkeys/triage.pub new file mode 100644 index 0000000..09a9aa1 --- /dev/null +++ b/testsuite/sshkeys/triage.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID35sECwJUlLdZIe2ChqBC+uTVdHdEgHbVeeX9h3tmBV bgit-testsuite-triage diff --git a/web.go b/web.go index ecc1279..8bcdd2b 100644 --- a/web.go +++ b/web.go @@ -40,6 +40,7 @@ const ( webCSSPath = "www/app.css" webJSPath = "www/app.js" webLogoPath = "www/bgit-mark.png" + webFaviconPath = "www/favicon.ico" ) type webOptions struct { @@ -392,6 +393,8 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) case route == "assets/bgit-mark.png": s.handleWebAsset(w, webLogoPath) + case route == "favicon.ico": + s.handleWebAsset(w, webFaviconPath) case route == "events": s.handleEvents(w, r) case route == "api/state": diff --git a/www/app.css b/www/app.css new file mode 100644 index 0000000..6a78ef9 --- /dev/null +++ b/www/app.css @@ -0,0 +1,2398 @@ +:root { + --logo-bg: #fdffff; + --ink: #101820; + --muted: #53606b; + --line: #d9e2e8; + --panel: #ffffff; + --green: #0f7f68; + --blue: #255f91; + --navy: #33475b; + --code-bg: #e8f1ff; + --code-ink: #164b8f; + --code-border: #b9d3ff; + --terminal-bg: #384b5f; + --terminal-ink: #f1f7ff; + --heading: #26394d; + --google-blue-soft: #e8f1ff; + --google-blue: var(--blue); + --shadow: 0 24px 70px rgba(16, 24, 32, 0.14); + --bg: #ffffff; + --surface: var(--panel); + --repo-area-bg: #ffffff; + --surface-2: #f6f9fb; + --surface-3: var(--code-bg); + --text: var(--ink); + --border: var(--line); + --border-strong: #b8c8d3; + --repo-border: #9fb1c0; + --repo-rail-width: 100%; + --repo-rail-margin: 0px; + --repo-side-width: 320px; + --repo-gap: 24px; + --repo-pad: 16px; + --repo-side-toolbar-offset: -62px; + --accent: var(--green); + --accent-2: var(--blue); + --accent-soft: var(--google-blue-soft); + --warning: #9a5b00; + --add: #0f7f68; + --del: #d37768; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:root[data-theme="dark"] { + --logo-bg: #1a2432; + --bg: #1a2432; + --ink: #f1f7ff; + --muted: #a8b5c1; + --line: #405266; + --panel: #1a2432; + --green: #7fb9ab; + --blue: #9dc8ff; + --navy: #dce9f5; + --code-bg: #1f3856; + --code-ink: #cfe4ff; + --code-border: #426c9c; + --terminal-bg: #0b1118; + --terminal-ink: #f1f7ff; + --heading: #f1f7ff; + --google-blue-soft: #182f49; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + --repo-area-bg: var(--panel); + --surface-2: #132032; + --surface-3: #1f3856; + --border-strong: #53697d; + --repo-border: #3d4c5d; + --warning: #d6b85e; + --add: #7fb9ab; + --del: #d37768; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --logo-bg: #1a2432; + --bg: #1a2432; + --ink: #f1f7ff; + --muted: #a8b5c1; + --line: #405266; + --panel: #1a2432; + --green: #7fb9ab; + --blue: #9dc8ff; + --navy: #dce9f5; + --code-bg: #1f3856; + --code-ink: #cfe4ff; + --code-border: #426c9c; + --terminal-bg: #0b1118; + --terminal-ink: #f1f7ff; + --heading: #f1f7ff; + --google-blue-soft: #182f49; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + --repo-area-bg: var(--panel); + --surface-2: #132032; + --surface-3: #1f3856; + --border-strong: #53697d; + --repo-border: #3d4c5d; + --warning: #d6b85e; + --add: #7fb9ab; + --del: #d37768; + } +} + +* { box-sizing: border-box; } +html { min-height: 100%; } +body { + min-height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); + font-size: 14px; + letter-spacing: 0; + line-height: 1.45; +} + +.sync-status { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 20; + display: flex; + min-height: 42px; + width: calc((var(--repo-rail-width) - var(--repo-side-width) - var(--repo-gap) - (2 * var(--repo-pad))) * 0.72); + max-width: calc(100vw - 28px); + align-items: center; + justify-content: center; + padding: 10px 44px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--accent-2) 26%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 18px 48px rgba(16, 24, 32, 0.18); + color: var(--heading); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.01em; + text-align: center; + opacity: 0; + transform: translate(-50%, 6px); + transition: opacity 160ms ease, transform 160ms ease; + pointer-events: none; +} +.sync-status::before { + position: absolute; + left: 18px; + display: block; + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent); + content: ""; +} +.sync-status.is-visible { + opacity: 1; + transform: translate(-50%, 0); +} +.sync-status.is-stale { + border-color: color-mix(in srgb, var(--warning) 42%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--warning); +} +.sync-status.is-error { + border-color: color-mix(in srgb, var(--del) 46%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--del); +} +.sync-status.is-current { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--accent); +} + +a { color: var(--accent-2); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 2px; } +a:focus-visible, button:focus-visible, select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-2) 70%, transparent); + outline-offset: 2px; +} +code, pre { font-family: var(--mono); } + +.layout { + width: min(100% - 48px, 1920px); + margin: 0 auto; + padding: 20px 0 42px; + display: block; +} +.repo-header { + position: relative; + margin-bottom: 18px; + padding: 18px 0 0; +} +h1 { + max-width: 100%; + margin: 0 0 12px; + color: var(--heading); + font-size: 24px; + font-weight: 800; + line-height: 1.2; + overflow-wrap: anywhere; +} +.repo-controls { + display: flex; + min-width: 0; + align-items: center; + justify-content: end; +} +.tabs-row { + position: relative; + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto); + align-items: stretch; + gap: 12px; + width: var(--repo-rail-width); + margin-right: auto; + margin-left: auto; + border-bottom: 1px solid var(--border); +} +.tabs-row .repo-controls { + min-height: 40px; +} +.repo-policy-banner { + width: var(--repo-rail-width); + box-sizing: border-box; + margin: 10px auto 0; + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--del) 35%, var(--repo-border)); + border-radius: 8px; + background: color-mix(in srgb, var(--del) 9%, var(--surface)); + color: var(--heading); + font-size: 13px; + font-weight: 800; +} +.repo-action-control { + display: flex; + min-height: 40px; + align-items: center; + gap: 8px; + justify-content: end; + min-width: 0; +} +.repo-action-button { + display: inline-flex; + min-height: 32px; + align-items: center; + justify-content: center; + padding: 0 15px; + border: 1px solid color-mix(in srgb, var(--del) 72%, var(--border)); + border-radius: 7px; + background: var(--del); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} +.repo-action-button[hidden] { + display: none; +} +.repo-action-button:hover { + filter: brightness(0.94); +} +.repo-action-button:disabled, +.inline-action:disabled { + cursor: wait; + filter: grayscale(0.18); + opacity: 0.72; +} +.is-capability-disabled { + cursor: not-allowed; + opacity: 0.55; +} +.repo-action-button.pull { + border-color: color-mix(in srgb, var(--warning) 72%, var(--border)); + background: var(--warning); +} +.repo-action-button.push { + border-color: color-mix(in srgb, var(--blue) 72%, var(--border)); + background: var(--blue); +} +.repo-action-button.uncommit { + border-color: color-mix(in srgb, var(--del) 60%, var(--border)); + background: color-mix(in srgb, var(--del) 86%, #000); +} +.repo-action-button.commit { + border-color: color-mix(in srgb, var(--del) 72%, var(--border)); + background: var(--del); +} +.repo-header-location { + max-width: min(42vw, 640px); + overflow: hidden; + color: var(--muted); + font-family: var(--mono); + font-size: 12px; + line-height: 1.2; + opacity: 0.62; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-toggle { + position: fixed; + top: 18px; + right: 18px; + z-index: 30; + display: inline-flex; + flex: 0 0 auto; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--code-ink); + cursor: pointer; +} +.theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); +} +.theme-symbol { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.theme-auto { + position: absolute; + right: 3px; + bottom: 1px; + display: none; + min-width: 13px; + height: 13px; + align-items: center; + justify-content: center; + border: 1px solid var(--code-border); + background: var(--panel); + color: var(--code-ink); + font-size: 9px; + font-weight: 800; + line-height: 1; +} +.theme-toggle[data-theme-state="auto"] .theme-auto { + display: inline-flex; +} +:root[data-theme="dark"] .theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); + } +} +h2 { + margin: 0 0 14px; + font-size: 21px; + color: var(--heading); + font-weight: 800; + line-height: 1.3; +} + +.remote-sync { + display: flex; + align-items: center; + justify-content: end; + gap: 6px; + height: 36px; + min-width: 0; +} +.remote-badge { + display: inline-flex; + width: 142px; + min-height: 24px; + align-items: center; + justify-content: center; + padding: 2px 10px; + overflow: hidden; + border: 1px solid var(--code-border); + border-radius: 999px; + background: var(--code-bg); + color: var(--code-ink); + font-size: 10px; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} +.remote-badge.is-syncing { + border-color: color-mix(in srgb, var(--warning) 58%, var(--border)); + background: color-mix(in srgb, var(--warning) 18%, var(--panel)); + color: var(--warning); +} +.remote-badge.is-current { + border-color: color-mix(in srgb, var(--add) 54%, var(--border)); + background: color-mix(in srgb, var(--add) 15%, var(--panel)); + color: var(--add); +} +.remote-badge.is-error { + border-color: color-mix(in srgb, var(--del) 58%, var(--border)); + background: color-mix(in srgb, var(--del) 14%, var(--panel)); + color: var(--del); +} +.remote-badge.is-ahead, +.remote-badge.is-dirty { + border-color: color-mix(in srgb, var(--del) 58%, var(--border)); + background: color-mix(in srgb, var(--del) 13%, var(--panel)); + color: var(--del); +} +.remote-badge.is-behind { + border-color: color-mix(in srgb, var(--warning) 64%, var(--border)); + background: color-mix(in srgb, var(--warning) 16%, var(--panel)); + color: var(--warning); + cursor: pointer; +} +.remote-refresh { + display: inline-flex; + width: 24px; + height: 36px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0; + background: transparent; + color: var(--code-ink); + cursor: pointer; + font: inherit; + font-size: 13px; + line-height: 1; +} +.remote-refresh svg { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.remote-refresh.is-current svg { + color: var(--add); +} +.remote-refresh:not(:disabled):hover { + color: var(--blue); +} +.remote-refresh:disabled { + cursor: default; + opacity: 0.72; +} +.remote-refresh.is-spinning { + animation: remote-refresh-spin 900ms linear infinite; +} +@keyframes remote-refresh-spin { + to { transform: rotate(360deg); } +} + +.ref-pill, +.ref-selector select { + min-height: 36px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--logo-bg); + color: var(--text); + box-shadow: none; +} +.ref-pill { + max-width: 320px; + padding: 8px 11px; + overflow: hidden; + font-family: var(--mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.ref-selector { + display: block; + min-width: 180px; + flex: 0 0 180px; +} +.ref-selector span { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} +.ref-selector select { + width: 100%; + padding: 0 34px 0 11px; + font: inherit; + font-size: 13px; +} + +.tabs { + display: flex; + gap: 2px; + min-width: 0; +} +.tabs a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 13px; + border-bottom: 2px solid transparent; + color: var(--muted); + font-weight: 800; +} +.tabs a.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.repo-toolbar { + position: relative; + z-index: 0; + display: grid; + grid-template-columns: minmax(0, auto) minmax(260px, 1fr); + align-items: center; + gap: 12px; + width: calc(100% - var(--repo-side-width) - var(--repo-gap) - (2 * var(--repo-pad))); + margin: 14px 0 10px; + margin-left: var(--repo-pad); +} +.repo-toolbar::before { + position: absolute; + z-index: -1; + top: -18px; + left: calc(-1 * var(--repo-pad)); + width: calc(100% + var(--repo-gap) + var(--repo-side-width) + (2 * var(--repo-pad))); + height: calc(100% + 28px); + background: var(--repo-area-bg); + content: ""; +} +.repo-toolbar-left, +.repo-toolbar-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.repo-toolbar-right { + justify-content: end; +} +.ref-count { + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 5px; + color: var(--heading); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} +.file-search { + position: relative; + flex: 0 1 260px; + min-width: 150px; +} +.file-search label { + display: block; +} +.file-search input { + width: 100%; + min-height: 36px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 13px; +} +.file-search-results { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + width: min(620px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 220px)); + overflow: auto; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.file-search-results a { + display: flex; + min-height: 34px; + align-items: center; + gap: 9px; + padding: 6px 10px; + border-radius: 6px; + color: var(--text); + font-size: 14px; + font-weight: 700; +} +.file-search-results a:hover, +.file-search-results a.active { + background: var(--code-bg); + text-decoration: none; +} +.file-search-icon { + width: 18px; + flex: 0 0 18px; + color: var(--muted); + text-align: center; +} +.code-menu { + position: relative; + flex: 0 0 auto; +} +.code-menu-button { + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 8px; + padding: 0 13px; + border: 1px solid color-mix(in srgb, var(--add) 54%, var(--border)); + border-radius: 6px; + background: var(--add); + color: var(--logo-bg); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; +} +.code-menu-popover { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 20; + width: min(440px, calc(100vw - 32px)); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.repo-content { + position: relative; + z-index: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--repo-side-width); + gap: var(--repo-gap); + align-items: start; + width: var(--repo-rail-width); + margin-right: auto; + margin-left: auto; + padding: var(--repo-pad); + background: var(--repo-area-bg); + clear: both; +} +.repo-primary { + display: grid; + gap: 14px; + width: 100%; + padding: 0; + background: var(--repo-area-bg); +} +.repo-primary > .panel { + margin: 0; + box-shadow: none; +} +.repo-content-single { + grid-template-columns: minmax(0, 1fr) var(--repo-side-width); + margin-top: -18px; + padding-top: 0; +} +.repo-content-single .repo-primary { + padding-top: var(--repo-pad); +} +.repo-content-single .repo-primary { + grid-column: 1; +} +.repo-side-panel { + position: relative; + z-index: 1; + display: grid; + gap: 16px; + margin-top: var(--repo-side-toolbar-offset); + width: calc(100% + var(--repo-pad)); + padding: 0; + padding-right: var(--repo-pad); + border: 0; + background: var(--repo-area-bg); + box-shadow: none; + color: var(--muted); +} +.side-panel-section { + padding-bottom: 16px; + border-bottom: 0; +} +.side-panel-section + .side-panel-section { + padding-top: 4px; +} +.side-panel-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} +.side-panel-section h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 10px; + color: var(--heading); + font-size: 15px; + font-weight: 800; +} +.side-panel-section p { + margin: 0 0 10px; + font-size: 13px; +} +.side-panel-section p:first-of-type { + text-align: justify; +} +.side-panel-section p a { + color: var(--accent-2); + font-weight: 800; + text-decoration: underline; + text-decoration-color: var(--accent-2); + text-underline-offset: 2px; +} +.side-panel-section p a:visited { + color: var(--accent-2); + text-decoration-color: var(--accent-2); +} +.repo-identity-name { + display: inline; + margin-left: 8px; + color: var(--heading); + font-size: 15px; + font-weight: 800; + line-height: 1.2; + overflow-wrap: anywhere; +} +.repo-identity h2 { + display: inline; +} +.side-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} +.side-links .icon-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-2); + font-size: 13px; + font-weight: 800; + text-decoration: none; +} +.side-links .icon-link:hover { + text-decoration: underline; +} +.side-links .icon-link svg { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.count-badge { + display: inline-flex; + min-width: 22px; + height: 22px; + align-items: center; + justify-content: center; + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--surface-2); + color: var(--heading); + font-size: 12px; +} +.contributors-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.contributors-list li { + display: flex; + min-width: 0; + align-items: center; + font-size: 13px; +} +.contributors-list span { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} +.panel, +.clone-panel { + display: block; + width: 100%; + clear: both; + margin: 14px 0; + overflow: hidden; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.panel-title { + padding: 11px 14px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface); + color: var(--heading); + font-size: 13px; + font-weight: 800; +} + +.clone-panel { + padding: 10px 12px 12px; +} +.clone-widget { + padding: 10px 12px 12px; +} +.clone-panel .clone-widget { + padding: 0; +} +.clone-panel .panel-title, +.clone-widget .panel-title { + padding: 0; + border: 0; + background: transparent; + color: var(--heading); + font-size: 17px; +} +.clone-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.clone-tabs { + display: flex; + gap: 18px; + min-width: 0; +} +.clone-tabs button { + min-height: 32px; + padding: 0 0 6px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 800; +} +.clone-tabs button.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.clone-pane { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; +} +.clone-pane[hidden] { display: none; } +.clone-download { + margin: 12px -12px -12px; + padding: 10px 12px; + border-top: 1px solid var(--border); +} +.clone-download a { + color: var(--heading); + font-weight: 800; +} +.kind { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} +.clone-pane code { + min-height: 34px; + overflow: hidden; + padding: 7px 10px; + border: 1px solid var(--code-border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} +.copy-button, +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 11px; + border: 1px solid var(--code-border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; +} +.copy-icon-button { + width: 34px; + min-width: 34px; + padding: 0; + font-size: 16px; +} +.copy-icon-button.is-copied { + border-color: color-mix(in srgb, var(--add) 54%, var(--border)); + background: color-mix(in srgb, var(--add) 15%, var(--panel)); + color: var(--add); +} +.copy-button:hover, +.button-link:hover { + border-color: rgba(37, 95, 145, 0.55); + background: #d9e9ff; + text-decoration: none; +} +:root[data-theme="dark"] .copy-button:hover, +:root[data-theme="dark"] .button-link:hover { + border-color: #5f8ec1; + background: #26476c; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .copy-button:hover, + :root:not([data-theme]) .button-link:hover { + border-color: #5f8ec1; + background: #26476c; + } +} + +.commit-strip { + display: flex; + gap: 12px; + align-items: center; + padding: 13px 14px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); +} +.commit-strip code, +.commits code, +.hash { + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} +.commit-hash-link { + text-decoration: none; +} +.commit-hash-link:hover code { + color: var(--accent-2); + text-decoration: underline; + text-underline-offset: 2px; +} +.commit-subject { + color: var(--heading); + font-weight: 800; + max-width: min(48ch, 45vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.commit-author { + color: var(--heading); + font-weight: 800; + white-space: nowrap; +} +.commit-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + white-space: nowrap; +} +.commit-when { + color: var(--muted); + white-space: nowrap; +} +.muted, +.meta { + color: var(--muted); + font-size: 13px; +} + +.files { + width: 100%; + border-collapse: collapse; +} +.files tr { + transition: background 120ms ease; +} +.files tr:hover { + background: var(--google-blue-soft); +} +.files tr.is-state-dirty, +.files tr.is-state-ahead, +.commits li.is-state-dirty, +.commits li.is-state-ahead { + background: color-mix(in srgb, var(--del) 8%, var(--panel)); +} +.files tr.is-state-behind, +.commits li.is-state-behind { + background: color-mix(in srgb, var(--warning) 10%, var(--panel)); +} +.files tr[hidden] { + display: none; +} +.files td { + padding: 10px 12px; + border-top: 1px solid var(--repo-border); + vertical-align: middle; +} +.files tr:first-child td { border-top: 0; } +.files td:nth-child(2) { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 620; +} +.kind { + width: 56px; + color: var(--muted); +} +.hash { + width: 118px; + text-align: right; +} + +.readme-panel { + margin-top: 0; +} +.readme-panel .panel-title { border-bottom: 1px solid var(--repo-border); } +.readme { + min-height: 110px; +} +.blob, +.readme pre, +.commit-message { + margin: 0; + padding: 15px; + overflow: auto; + background: var(--panel); + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.blob { + background: var(--panel); +} +.blob-toolbar { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.blob-toolbar .panel-title { + padding: 0; + border: 0; + background: transparent; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + align-items: center; + margin: 10px 14px 14px; + font-size: 13px; +} +.blob-toolbar .actions { margin: 0; } + +.commits { + margin: 0; + padding: 0; + list-style: none; +} +.commits li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 8px 10px; + border-top: 1px solid var(--border-strong); +} +.commits li[data-commit-href] { + cursor: pointer; +} +.commits li:first-child { border-top: 0; } +.commits li:hover { background: var(--google-blue-soft); } +.commits li.is-selected-commit { + background: color-mix(in srgb, var(--google-blue-soft) 45%, var(--panel)); +} +.commit-row-main { + min-width: 0; +} +.commit-row-meta { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + margin-left: auto; +} +.commit-row-meta .inline-icon-action { + flex: 0 0 auto; +} +.commit-inline-detail { + grid-column: 1 / -1; + margin-top: 8px; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; + background: var(--panel); +} +.commit-inline-detail > .panel { + margin: 0; + border-right: 0; + border-left: 0; + border-radius: 0; + box-shadow: none; +} +.commit-inline-detail > .panel:first-of-type { + border-top: 0; +} +.commit-inline-detail .visual-diff { + padding: 0; +} + +.state-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + vertical-align: middle; +} +.state-marker { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + white-space: nowrap; + vertical-align: middle; +} +.state-marker > span { + display: inline-flex; + align-items: center; + min-height: 19px; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0; +} +.state-dirty > span, +.state-ahead > span { + border-color: color-mix(in srgb, var(--del) 52%, var(--border)); + background: color-mix(in srgb, var(--del) 12%, var(--panel)); + color: var(--del); +} +.state-behind > span { + border-color: color-mix(in srgb, var(--warning) 58%, var(--border)); + background: color-mix(in srgb, var(--warning) 15%, var(--panel)); + color: var(--warning); +} +.inline-action { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--panel); + color: var(--link); + font: inherit; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} +.inline-action:hover { + border-color: var(--google-blue); + background: var(--google-blue-soft); +} +.inline-icon-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--panel); + color: var(--muted); + cursor: pointer; +} +.inline-icon-action svg { + width: 15px; + height: 15px; +} +.inline-icon-action:hover { + border-color: var(--google-blue); + background: var(--google-blue-soft); + color: var(--google-blue); +} +.inline-icon-action.is-active { + border-color: var(--google-blue); + background: var(--google-blue-soft); + color: var(--google-blue); +} +.inline-icon-action:disabled { + cursor: wait; + opacity: 0.55; +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(16, 24, 34, 0.42); +} +.modal-card { + width: min(520px, 100%); + border: 1px solid var(--repo-border); + border-radius: 12px; + background: var(--panel); + box-shadow: var(--shadow); + padding: 20px; +} +.modal-card h2 { + margin: 0 0 8px; + font-size: 18px; +} +.modal-card p { + margin: 0 0 16px; + color: var(--muted); + line-height: 1.45; +} +.modal-field { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 13px; + font-weight: 800; +} +.modal-field input, +.modal-field textarea { + width: 100%; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 7px 10px; +} +.modal-field input { + min-height: 38px; +} +.modal-field textarea { + min-height: 132px; + resize: vertical; + line-height: 1.45; +} +.modal-error { + margin-top: 8px; + color: var(--del); + font-size: 13px; +} +.modal-file-list { + margin: 0 0 16px; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface-2); +} +.modal-file-list > div { + padding: 9px 11px; + border-bottom: 1px solid var(--repo-border); + color: var(--heading); + font-size: 12px; + font-weight: 900; +} +.modal-file-list ul { + max-height: 180px; + margin: 0; + padding: 6px 0; + overflow: auto; + list-style: none; +} +.modal-file-list li { + padding: 5px 11px; + color: var(--text); + font-family: var(--mono); + font-size: 12px; + overflow-wrap: anywhere; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} +.inline-diff-row > td { + padding: 0; + background: var(--panel); +} +.commits li.inline-diff-row { + display: block; + padding: 0; +} +.pr-detail-header + .inline-diff-row { + display: block; + margin-top: 10px; +} +.inline-diff-shell { + padding: 0 0 12px 0; + border-top: 1px solid var(--repo-border); + background: var(--panel); +} +.inline-diff-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + color: var(--heading); + font-size: 12px; +} +.inline-diff-header strong { + font-family: var(--mono); + overflow-wrap: anywhere; +} +.inline-diff-header span { + color: var(--muted); +} +.pr-nav-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 14px; +} +.visual-diff { + padding: 0 12px; + overflow: auto; + background: var(--panel); +} +.visual-diff-file { + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; + background: var(--panel); +} +.visual-diff-file + .visual-diff-file { + margin-top: 14px; +} +.visual-diff-title { + padding: 10px 12px; + border-bottom: 1px solid var(--repo-border); + color: var(--heading); + font-family: var(--mono); + font-size: 12px; + font-weight: 800; + overflow-wrap: anywhere; +} +.visual-diff-grid { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 56px minmax(0, 1fr); + overflow: auto; +} +.visual-diff-heading { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); + color: var(--heading); + font-size: 12px; + font-weight: 900; +} +.visual-diff-line-heading { + padding: 8px 0; +} +.visual-diff-row { + display: contents; +} +.visual-diff-row[hidden] { + display: none; +} +.visual-diff-divider { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + background: color-mix(in srgb, var(--google-blue) 9%, var(--panel)); + color: var(--link); + font: 800 12px/1.45 var(--mono); +} +.visual-diff-hunk-bottom { + min-height: 28px; +} +.diff-context-controls { + display: inline-flex; + align-items: center; + gap: 4px; +} +.diff-context-controls button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 1px solid color-mix(in srgb, var(--google-blue) 35%, var(--repo-border)); + border-radius: 5px; + background: var(--panel); + color: var(--link); + font: 800 12px/1 var(--mono); + cursor: pointer; +} +.diff-context-controls button:hover { + background: var(--google-blue-soft); +} +.diff-context-controls button:disabled { + opacity: 0.45; + cursor: default; +} +.diff-context-controls button:disabled:hover { + background: var(--panel); +} +.visual-diff-line-number { + min-height: 28px; + padding: 6px 8px; + background: var(--surface-2); + color: var(--muted); + text-align: right; + user-select: none; + font: 11px/1.45 var(--mono); +} +.visual-diff-row pre { + position: relative; + min-height: 28px; + margin: 0; + padding: 6px 10px; + white-space: pre-wrap; + word-break: break-word; + font: 12px/1.45 var(--mono); +} +.review-comment-target { + padding-right: 38px; +} +.review-comment-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + border: 1px solid var(--repo-border); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + cursor: pointer; + font-size: 13px; +} +.comment-count { + position: absolute; + top: -7px; + right: -7px; + min-width: 15px; + height: 15px; + padding: 0 4px; + border-radius: 999px; + background: var(--del); + color: #fff; + font-size: 10px; + font-weight: 900; + line-height: 15px; +} +.line-comment { + position: absolute; + right: 6px; + bottom: 3px; + opacity: 0; +} +.review-comment-target:hover .line-comment, +.line-comment:focus-visible { + opacity: 1; +} +.review-draft-row pre, +.review-draft-comment { + background: transparent; +} +.review-thread { + display: grid; + gap: 8px; + margin: 10px 14px; +} +.review-thread-comment { + padding: 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.review-draft-comment { + margin: 10px 14px; + padding: 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.inline-draft-editor { + margin: 10px 14px; +} +.pr-inline-after-context > .inline-draft-editor, +.pr-inline-reply-editor { + grid-column: 2; + min-width: 0; + margin: 9px 10px 10px; +} +.review-draft-editor-row pre { + background: transparent; +} +.inline-draft-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 10px; + border: 1px solid var(--repo-border); + border-left: 3px solid var(--blue); + border-radius: 0 7px 7px 0; + background: var(--surface-2); +} +.inline-draft-box textarea { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 8px 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.45; +} +.inline-draft-actions { + display: flex; + gap: 7px; + align-items: center; +} +.inline-draft-error { + display: none; + grid-column: 1 / -1; + color: var(--del); + font-size: 12px; + font-weight: 800; +} +.inline-draft-editor.has-error .inline-draft-error, +.review-draft-editor-row.has-error .inline-draft-error { + display: block; +} +.visual-diff-note { + color: var(--muted); + font-style: italic; +} +.visual-diff .diff-change { + border-radius: 3px; + padding: 1px 2px; + font-weight: 800; +} +.visual-diff .diff-change.deleted { + background: color-mix(in srgb, var(--del) 24%, transparent); + color: var(--del); + text-decoration: line-through; +} +.visual-diff .diff-change.added { + background: color-mix(in srgb, var(--add) 24%, transparent); + color: var(--add); +} +.button-link.primary { + border-color: var(--blue); + background: var(--blue); + color: #fff; +} + +.pr-list { + margin: 0; + padding: 0; + list-style: none; +} +.pr-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-top: 1px solid var(--border); + cursor: pointer; +} +.pr-item:first-child { border-top: 0; } +.pr-item:hover { background: var(--google-blue-soft); } +.pr-main { + min-width: 0; +} +.pr-title { + color: var(--heading); + text-decoration: none; +} +.pr-title:hover { + color: var(--accent-2); + text-decoration: underline; +} +.pr-main strong { + color: var(--heading); + font-weight: 800; +} +.pr-id { + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} +.pr-meta { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + gap: 8px; +} +.pr-status, +.pr-approvals { + display: inline-flex; + min-height: 24px; + align-items: center; + padding: 3px 7px; + border: 1px solid var(--code-border); + background: var(--code-bg); + color: var(--code-ink); + font-size: 11px; + font-weight: 800; +} +.pr-detail-header { + padding: 14px 16px 0; +} +.pr-detail-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.pr-detail-title h2 { + margin: 0; + font-size: 20px; + overflow-wrap: anywhere; +} +.pr-subtabs { + display: flex; + gap: 3px; + margin: 14px -16px 0; + padding: 0 13px; + border-top: 1px solid var(--repo-border); +} +.pr-subtabs a { + display: inline-flex; + min-height: 38px; + align-items: center; + padding: 0 11px; + border-bottom: 2px solid transparent; + color: var(--muted); + font-weight: 800; +} +.pr-subtabs a.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.pr-body { + padding: 14px; + white-space: pre-wrap; +} +.pr-conversation { + display: grid; + max-height: calc(100vh - 132px); + grid-template-rows: auto auto minmax(0, 1fr) auto; +} +.pr-timeline { + min-height: 0; + overflow: auto; + border-top: 1px solid var(--repo-border); +} +.pr-note { + padding: 13px 14px; + border-bottom: 1px solid var(--repo-border); +} +.pr-note-meta { + display: flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 13px; +} +.pr-note-meta strong { + color: var(--heading); +} +.pr-note-meta span { + margin-left: 1px; +} +.pr-note-body { + margin-top: 8px; + white-space: pre-wrap; + color: var(--text); +} +.pr-inline-comments { + display: grid; + gap: 12px; + margin-top: 12px; +} +.pr-inline-comment { + overflow: hidden; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--panel); +} +.pr-inline-context-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); + color: var(--heading); + font-family: var(--mono); + font-size: 12px; + font-weight: 800; +} +.pr-reply-button { + width: 26px; + height: 22px; + margin-left: auto; + font-size: 12px; +} +.pr-reply-thread { + display: grid; + gap: 9px; + margin: 10px 10px 10px 34px; +} +.pr-reply-thread .pr-reply-thread { + margin-left: 24px; +} +.pr-reply { + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.pr-note.is-scroll-target, +.pr-inline-comment.is-scroll-target, +.pr-reply.is-scroll-target { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +.pr-reply-meta { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 5px; + color: var(--muted); + font-size: 12px; +} +.pr-reply-meta strong { + color: var(--heading); +} +.pr-inline-context-line, +.pr-inline-outdated { + color: var(--muted); + font-family: var(--sans); + font-size: 12px; + font-weight: 800; +} +.pr-inline-outdated { + color: var(--del); +} +.pr-inline-after-context { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); +} +.pr-inline-line-number { + min-height: 30px; + padding: 7px 8px; + background: var(--surface-2); + color: var(--muted); + text-align: right; + user-select: none; + font: 11px/1.45 var(--mono); +} +.pr-inline-line-code { + min-height: 30px; + margin: 0; + padding: 7px 10px; + background: var(--panel); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + font: 12px/1.45 var(--mono); +} +.pr-inline-target-line { + background: color-mix(in srgb, var(--add) 8%, var(--panel)); + box-shadow: inset 3px 0 0 var(--add); +} +.pr-inline-comment-body { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin: 9px 10px 10px; + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-left: 3px solid var(--blue); + border-radius: 0 7px 7px 0; + background: var(--surface-2); + color: var(--text); + white-space: pre-wrap; +} +.pr-inline-body-reply { + flex: 0 0 auto; + margin-left: 8px; +} +.pr-inline-file-comment { + padding: 10px 14px; +} +.pr-inline-file-target { + display: inline-flex; + margin-bottom: 8px; + padding: 3px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--add) 11%, var(--panel)); + color: var(--add); + font-size: 12px; + font-weight: 900; +} +.pr-review-form { + position: sticky; + bottom: 0; + z-index: 3; + padding: 14px; + border-top: 1px solid var(--repo-border); + background: var(--panel); +} +.pr-review-form label { + display: block; + margin-bottom: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.pr-review-form textarea { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 96px; + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; +} +.pr-review-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; +} +.pr-review-actions [data-review-cancel] { + order: 20; + margin-left: auto; +} +.pr-review-actions .pr-delete-branch { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0 8px 0 auto; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.pr-closed-note { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--repo-border); + color: var(--muted); +} + +.settings-primary { + max-width: 980px; +} +.settings-panel { + overflow: visible; +} +.settings-section { + padding: 16px; + border-bottom: 1px solid var(--repo-border); +} +.settings-section:last-child { + border-bottom: 0; +} +.settings-section h2 { + margin: 0 0 12px; + color: var(--heading); + font-size: 15px; +} +.settings-section h3 { + margin: 0 0 10px; + color: var(--heading); + font-size: 13px; +} +.settings-form { + display: grid; + gap: 12px; +} +.settings-member-form, +.settings-protection-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--repo-border); +} +.settings-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.settings-form label { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.settings-form input, +.settings-form select, +.settings-form textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 500; + padding: 8px 10px; +} +.settings-form textarea { + resize: vertical; + line-height: 1.45; +} +.settings-check { + align-content: center; + grid-template-columns: auto 1fr; + gap: 8px; + color: var(--text); +} +.settings-check input { + width: auto; +} +.settings-actions { + display: flex; + justify-content: end; +} +.settings-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} +.settings-meta-grid div, +.settings-info-list div { + min-width: 0; + padding: 11px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.settings-meta-grid span, +.settings-info-list span, +.settings-row span, +.settings-row small { + display: block; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} +.settings-meta-grid strong, +.settings-info-list strong, +.settings-row strong { + display: block; + min-width: 0; + overflow-wrap: anywhere; + color: var(--heading); +} +.settings-table { + display: grid; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; +} +.settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 11px 12px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface); +} +.settings-row:last-child { + border-bottom: 0; +} +.settings-row-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + justify-content: end; +} +.settings-note { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} +.settings-danger { + border: 1px solid color-mix(in srgb, var(--del) 45%, var(--repo-border)); + border-radius: 8px; + margin: 16px; +} +.settings-warning { + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 7px; + background: color-mix(in srgb, var(--del) 11%, var(--surface)); + color: var(--heading); + font-size: 13px; + line-height: 1.45; +} +.button-link.danger { + border-color: color-mix(in srgb, var(--del) 55%, var(--repo-border)); + color: var(--del); +} +.settings-info-list { + display: grid; + gap: 10px; +} +.settings-error { + margin-top: 8px; + color: var(--del); + font-size: 13px; +} + +.issue-list { + display: grid; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; +} +.issue-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--repo-border); + color: var(--text); + text-decoration: none; +} +.issue-row:last-child { border-bottom: 0; } +.issue-row strong { color: var(--heading); } +.issue-row small { + display: block; + margin-top: 3px; + color: var(--muted); +} +.issue-state { + align-self: start; + border: 1px solid var(--repo-border); + border-radius: 999px; + padding: 3px 7px; + color: var(--muted); + font-size: 10px; + font-weight: 900; +} +.issue-state.open { + color: var(--add); + border-color: color-mix(in srgb, var(--add) 45%, var(--repo-border)); +} +.issue-state.closed { + color: var(--muted); +} +.issue-form { + display: grid; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--repo-border); +} +.issue-form label { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.issue-form input, +.issue-form textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 8px 10px; +} +.issue-heading { + display: flex; + gap: 10px; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--repo-border); +} +.issue-heading h1 { + margin: 0; + color: var(--heading); + font-size: 20px; +} +.issue-comment { + margin: 14px 16px; + padding: 12px; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface-2); +} +.issue-comment strong { + color: var(--heading); + font-size: 13px; +} +.issue-comment p { + margin: 8px 0 0; + white-space: pre-wrap; +} + +.commit-detail { padding: 17px; } +.commit-detail .commit-message { + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); +} +.metadata-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.metadata-grid div { + min-width: 0; + padding: 11px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--surface); +} +.metadata-grid span { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} +.metadata-grid small { + display: block; + color: var(--muted); + overflow-wrap: anywhere; +} + +.diff-summary { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.additions { color: var(--add); font-weight: 800; } +.deletions { color: var(--del); font-weight: 800; } +.changed-files .diff-stat { + width: 120px; + text-align: right; +} +.diff-file { scroll-margin-top: 16px; } +.diff-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); + overflow-wrap: anywhere; +} +.diff-header-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} +.pr-review-submit { + position: sticky; + bottom: 12px; + z-index: 12; + display: grid; + gap: 8px; + margin-top: 14px; + padding: 12px; + border: 1px solid var(--repo-border); + border-radius: 10px; + background: var(--panel); + box-shadow: var(--shadow); +} +.pr-review-submit textarea { + width: 100%; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 8px 10px; +} +.review-help { + padding: 12px 14px; +} +.diff { + margin: 0; + overflow: auto; + background: var(--surface); + font-size: 12px; + line-height: 1.5; +} +.diff-line { + display: block; + min-height: 18px; + padding: 0 12px; + white-space: pre; +} +.diff-line.add { background: color-mix(in srgb, var(--add) 15%, transparent); } +.diff-line.del { background: color-mix(in srgb, var(--del) 14%, transparent); } +.diff-line.hunk { + background: var(--code-bg); + color: var(--code-ink); + font-weight: 800; +} + +.empty { + margin: 14px; + padding: 14px; + border: 1px dashed var(--border-strong); + border-radius: 0; + background: var(--surface); + color: var(--muted); +} +.sr-only { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +@media (max-width: 1080px) { + :root { + --repo-side-width: 280px; + --repo-gap: 18px; + } +} + +@media (max-width: 920px) { + .layout { width: min(100vw - 28px, 1180px); padding: 12px 0 28px; } + .sync-status { width: min(70vw, calc(100vw - 28px)); } + .tabs-row { + grid-template-columns: 1fr; + gap: 6px; + } + .tabs, + .tabs-row, + .repo-toolbar, + .repo-content { width: 100%; } + .tabs { + overflow-x: auto; + } + .repo-action-control, + .repo-controls { + align-items: end; + justify-content: start; + flex-wrap: wrap; + } + .repo-toolbar { + display: flex; + align-items: stretch; + flex-direction: column; + margin: 14px 0 10px; + } + .repo-toolbar::before { + left: 0; + width: 100%; + } + .repo-side-panel { + width: 100%; + margin-top: 0; + padding-right: 0; + } + .repo-toolbar-left, + .repo-toolbar-right { flex-wrap: wrap; justify-content: start; } + .file-search { flex: 1 1 100%; width: 100%; } + .file-search-results { left: 0; right: auto; } + .code-menu-popover { left: 0; right: auto; } + .remote-sync { justify-content: start; } + .ref-selector { max-width: none; flex: 1 1 220px; } + .clone-head { display: grid; gap: 8px; } + .clone-tabs { gap: 14px; overflow-x: auto; } + .clone-pane { grid-template-columns: 1fr; } + .blob-toolbar, + .diff-header { + flex-direction: column; + align-items: stretch; + } + .commit-strip { + gap: 4px; + flex-wrap: wrap; + } + .commit-subject { + max-width: 100%; + } + .repo-content { + grid-template-columns: 1fr; + } + .settings-form-grid, + .settings-meta-grid { + grid-template-columns: 1fr; + } + .settings-row { + grid-template-columns: 1fr; + } + .settings-row-actions { + justify-content: start; + } + .pr-item { flex-direction: column; } + .pr-meta { flex-wrap: wrap; } + .hash { display: none; } + .metadata-grid { grid-template-columns: 1fr; } + .files td { padding: 9px 10px; } + .kind { width: 48px; } +} + +@media (max-width: 560px) { + :root { + --repo-pad: 10px; + --repo-gap: 12px; + } + .layout { + width: min(100vw - 16px, 1180px); + } + .tabs a { + padding: 0 10px; + white-space: nowrap; + } + .repo-action-button { + flex: 1 1 auto; + padding: 0 10px; + } + .repo-header-location { + max-width: 100%; + } + .visual-diff-grid { + grid-template-columns: 44px minmax(220px, 1fr) 44px minmax(220px, 1fr); + } +} diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..a8be870 --- /dev/null +++ b/www/app.js @@ -0,0 +1,1953 @@ +const reviewDraftComments = []; +let restoringReviewDraft = false; +const prScrollTargetKey = 'bgit.prScrollTarget'; +let currentWhoami = window.BGIT_WHOAMI || null; + +document.addEventListener('click', function (event) { + const contextButton = event.target.closest('[data-diff-context]'); + if (contextButton) { + event.preventDefault(); + revealDiffContext(contextButton); + return; + } + + const fileResult = event.target.closest('[data-file-search-result]'); + if (fileResult) { + event.preventDefault(); + window.location.href = fileResult.getAttribute('data-file-search-result'); + return; + } + + const prItem = event.target.closest('[data-pr-href]'); + if (prItem && !event.target.closest('a, button, input, textarea, select, label')) { + event.preventDefault(); + window.location.href = prItem.getAttribute('data-pr-href'); + return; + } + + const commitRow = event.target.closest('[data-commit-href]'); + if (commitRow && !event.target.closest('a, button, input, textarea, select, label, .commit-inline-detail')) { + event.preventDefault(); + window.location.href = commitRow.getAttribute('data-commit-href'); + return; + } + + const codeToggle = event.target.closest('[data-code-menu-toggle]'); + if (codeToggle) { + const menu = codeToggle.closest('.code-menu'); + const popover = menu ? menu.querySelector('[data-code-menu]') : null; + if (!popover) return; + const open = popover.hidden; + closeCodeMenus(menu); + popover.hidden = !open; + codeToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + return; + } + + if (!event.target.closest('.code-menu')) { + closeCodeMenus(null); + } + if (!event.target.closest('.file-search')) { + closeFileSearchResults(); + } + + const refCount = event.target.closest('[data-focus-ref-selector]'); + if (refCount) { + event.preventDefault(); + const selector = document.querySelector('[data-ref-selector]'); + if (selector) { + selector.focus(); + if (typeof selector.showPicker === 'function') { + try { selector.showPicker(); } catch (_) {} + } + } + return; + } + + const refresh = event.target.closest('[data-remote-refresh]'); + if (refresh) { + refreshRemoteState({manual: true, refreshPullRequests: true}); + return; + } + + const syncBadge = event.target.closest('[data-remote-sync-badge]'); + if (syncBadge && currentWebState && Number(currentWebState.behind || 0) > 0) { + handleWebAction('pull'); + return; + } + + const diffAction = event.target.closest('[data-web-diff]'); + if (diffAction) { + event.preventDefault(); + showInlineDiff(diffAction); + return; + } + + const webAction = event.target.closest('[data-web-action]'); + if (webAction) { + event.preventDefault(); + if (!hasCapability(webAction.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + handleWebAction(webAction.getAttribute('data-web-action'), webAction); + return; + } + + const prAction = event.target.closest('[data-pr-action]'); + if (prAction) { + event.preventDefault(); + if (!hasCapability(prAction.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + handlePullRequestAction(prAction); + return; + } + + const prReply = event.target.closest('[data-pr-reply]'); + if (prReply) { + event.preventDefault(); + showPullRequestReplyEditor(prReply); + return; + } + + const reviewCommentButton = event.target.closest('[data-review-comment-line], [data-review-comment-file]'); + if (reviewCommentButton) { + event.preventDefault(); + showReviewDraftEditor(reviewCommentButton); + return; + } + + const draftOK = event.target.closest('[data-draft-ok]'); + if (draftOK) { + event.preventDefault(); + submitInlineDraft(draftOK); + return; + } + + const draftCancel = event.target.closest('[data-draft-cancel]'); + if (draftCancel) { + event.preventDefault(); + const editor = draftCancel.closest('[data-draft-editor]'); + if (editor) editor.remove(); + saveReviewDraftState(); + return; + } + + const reviewSubmit = event.target.closest('[data-pr-review-action]'); + if (reviewSubmit) { + event.preventDefault(); + if (!hasCapability(reviewSubmit.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + submitReviewDraft(reviewSubmit); + return; + } + + const reviewCancel = event.target.closest('[data-review-cancel]'); + if (reviewCancel) { + clearStoredReviewDraft(currentReviewID()); + return; + } + + const settingsAction = event.target.closest('[data-settings-action]'); + if (settingsAction) { + event.preventDefault(); + handleSettingsAction(settingsAction); + return; + } + + const issueAction = event.target.closest('[data-issue-action]'); + if (issueAction) { + event.preventDefault(); + handleIssueAction(issueAction); + return; + } + + const cloneTab = event.target.closest('[data-clone-tab]'); + if (cloneTab) { + const panel = cloneTab.closest('.clone-panel'); + const target = cloneTab.getAttribute('data-clone-tab'); + if (!panel || !target) return; + for (const tab of panel.querySelectorAll('[data-clone-tab]')) { + const active = tab === cloneTab; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + } + for (const pane of panel.querySelectorAll('[data-clone-pane]')) { + pane.hidden = pane.getAttribute('data-clone-pane') !== target; + } + return; + } + + const button = event.target.closest('[data-copy-target]'); + if (!button) return; + const target = document.getElementById(button.getAttribute('data-copy-target')); + if (!target) return; + const value = target.value !== undefined ? target.value : target.textContent; + navigator.clipboard.writeText(value).then(function () { + if (button.hasAttribute('data-copy-icon')) { + const oldTitle = button.getAttribute('title') || 'Copy'; + const oldLabel = button.getAttribute('aria-label') || 'Copy'; + button.classList.add('is-copied'); + button.setAttribute('title', 'Copied'); + button.setAttribute('aria-label', 'Copied'); + window.setTimeout(function () { + button.classList.remove('is-copied'); + button.setAttribute('title', oldTitle); + button.setAttribute('aria-label', oldLabel); + }, 1200); + return; + } + const old = button.textContent; + button.textContent = 'Copied'; + window.setTimeout(function () { button.textContent = old; }, 1200); + }); +}); + +document.addEventListener('change', function (event) { + const select = event.target.closest('[data-ref-selector]'); + if (!select) return; + const url = new URL(window.location.href); + url.searchParams.set('ref', select.value); + window.location.href = url.toString(); +}); + +document.addEventListener('submit', function (event) { + const form = event.target.closest('[data-settings-form]'); + if (form) { + event.preventDefault(); + handleSettingsForm(form); + return; + } + const issueForm = event.target.closest('[data-issue-form]'); + if (issueForm) { + event.preventDefault(); + handleIssueForm(issueForm); + } +}); + +document.addEventListener('input', function (event) { + const input = event.target.closest('[data-file-search]'); + if (!input) return; + renderFileSearchResults(input); +}); + +document.addEventListener('input', function (event) { + if (event.target.closest('[data-pr-review-note], [data-draft-editor] [data-draft-text]')) { + saveReviewDraftState(); + } +}); + +document.addEventListener('keydown', function (event) { + const input = event.target.closest('[data-file-search]'); + if (!input || event.key !== 'Enter') return; + const match = findIndexedFile(input.value); + if (!match) return; + event.preventDefault(); + window.location.href = match.url; +}); + +document.addEventListener('DOMContentLoaded', function () { + setupThemeToggle(); + setupReviewDiff(); + restorePullRequestScrollTarget(); + setWhoamiState(currentWhoami); + restoreSettingsStatus(); + connectBgitEvents(); + refreshWhoamiState(); + hydrateRefs(); + refreshRemoteState({refreshPullRequests: false}); + window.setInterval(function () { refreshRemoteState({refreshPullRequests: true}); }, 30000); +}); + +function setupReviewDiff() { + const review = document.querySelector('[data-pr-review-diff]'); + if (!review) return; + const existing = readReviewComments(); + for (const file of review.querySelectorAll('[data-review-file]')) { + const path = file.getAttribute('data-review-file') || ''; + const fileComments = existing.filter((comment) => comment.file === path && comment.kind === 'file'); + fileComments.forEach((comment) => { comment._matched = true; }); + const fileButton = file.querySelector('[data-review-comment-file]'); + if (fileButton && fileComments.length) fileButton.innerHTML = 'šŸ’¬' + fileComments.length + ''; + if (fileComments.length) { + file.querySelector('.diff-header')?.insertAdjacentHTML('afterend', reviewThreadHTML(fileComments)); + } + for (const row of file.querySelectorAll('.visual-diff-row')) { + const newCell = row.querySelector('pre[data-new-line]'); + const line = newCell ? newCell.getAttribute('data-new-line') : ''; + if (!newCell || !line) continue; + newCell.classList.add('review-comment-target'); + const rowComments = existing.filter((comment) => comment.file === path && comment.kind === 'line' && Number(comment.line || 0) === Number(line)); + rowComments.forEach((comment) => { comment._matched = true; }); + newCell.insertAdjacentHTML('beforeend', ''); + if (rowComments.length) row.insertAdjacentHTML('afterend', '

    ' + reviewThreadHTML(rowComments) + '
    '); + } + const orphaned = existing.filter((comment) => comment.file === path && comment.kind === 'line' && !comment._matched); + if (orphaned.length) { + file.querySelector('.visual-diff-grid')?.insertAdjacentHTML('beforeend', '
    Outdated comments
    ' + reviewThreadHTML(orphaned) + '
    '); + } + } + restoreReviewDraftState(); +} + +function readReviewComments() { + const node = document.getElementById('pr-review-comments'); + if (!node) return []; + try { + const comments = JSON.parse(node.textContent || '[]'); + return Array.isArray(comments) ? comments : []; + } catch (_) { + return []; + } +} + +function reviewThreadHTML(comments) { + return '
    ' + comments.map(function (comment) { + return '
    ' + escapeHTML(comment.user || 'unknown') + ' commented' + (comment.at ? ' ' + escapeHTML(comment.at) + '' : '') + '
    ' + escapeHTML(comment.body || '') + '
    '; + }).join('') + '
    '; +} + +function closeCodeMenus(except) { + for (const menu of document.querySelectorAll('.code-menu')) { + if (except && menu === except) continue; + const popover = menu.querySelector('[data-code-menu]'); + const toggle = menu.querySelector('[data-code-menu-toggle]'); + if (popover) popover.hidden = true; + if (toggle) toggle.setAttribute('aria-expanded', 'false'); + } +} + +function indexedFiles() { + if (indexedFiles.cache) return indexedFiles.cache; + const el = document.getElementById('bgit-file-index'); + if (!el) { + indexedFiles.cache = []; + return indexedFiles.cache; + } + try { + const parsed = JSON.parse(el.textContent || '[]'); + indexedFiles.cache = Array.isArray(parsed) ? parsed : []; + } catch (_) { + indexedFiles.cache = []; + } + return indexedFiles.cache; +} + +function findIndexedFile(value) { + const query = String(value || '').trim().toLowerCase(); + if (!query) return null; + const files = rankedIndexedFiles(query); + return files[0] || null; +} + +function rankedIndexedFiles(query) { + query = String(query || '').trim().toLowerCase(); + if (!query) return []; + const exact = []; + const prefix = []; + const segmentPrefix = []; + for (const file of indexedFiles()) { + const path = String(file.path || ''); + const lower = path.toLowerCase(); + if (lower === query) exact.push(file); + else if (lower.startsWith(query)) prefix.push(file); + else if (lower.split('/').some(function (part) { return part.startsWith(query); })) segmentPrefix.push(file); + } + return exact.concat(prefix, segmentPrefix); +} + +function renderFileSearchResults(input) { + const results = document.querySelector('[data-file-search-results]'); + if (!results) return; + const matches = rankedIndexedFiles(input.value).slice(0, 12); + if (matches.length === 0) { + results.hidden = true; + input.setAttribute('aria-expanded', 'false'); + results.innerHTML = ''; + return; + } + results.innerHTML = matches.map(function (file, index) { + const icon = file.kind === 'dir' ? 'ā–£' : 'ā–Æ'; + return '
    ' + escapeHTML(file.path || '') + ''; + }).join(''); + results.hidden = false; + input.setAttribute('aria-expanded', 'true'); +} + +function closeFileSearchResults() { + const results = document.querySelector('[data-file-search-results]'); + const input = document.querySelector('[data-file-search]'); + if (results) { + results.hidden = true; + results.innerHTML = ''; + } + if (input) input.setAttribute('aria-expanded', 'false'); +} + +let remoteRefreshInFlight = false; +let remoteSyncInitialized = false; +let currentWebState = null; + +function setupThemeToggle() { + const button = document.querySelector('[data-theme-toggle]'); + if (!button) return; + const storageKey = 'bgit.theme'; + const media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + let longPressTimer = 0; + let longPressed = false; + + const storedTheme = function () { + try { + const theme = localStorage.getItem(storageKey); + return theme === 'light' || theme === 'dark' ? theme : ''; + } catch (_) { + return ''; + } + }; + const systemTheme = function () { + return media && media.matches ? 'dark' : 'light'; + }; + const apply = function () { + const theme = storedTheme(); + if (theme) { + document.documentElement.dataset.theme = theme; + button.dataset.themeState = theme; + button.setAttribute('aria-label', 'Switch to ' + (theme === 'dark' ? 'light' : 'dark') + ' theme'); + } else { + delete document.documentElement.dataset.theme; + button.dataset.themeState = 'auto'; + button.setAttribute('aria-label', 'Theme follows system preference'); + } + }; + const setTheme = function (theme) { + try { + localStorage.setItem(storageKey, theme); + } catch (_) {} + apply(); + setSyncStatus('Switched to ' + theme + '. Long-press to reset to system preferences.', 'is-current'); + }; + const clearTheme = function () { + try { + localStorage.removeItem(storageKey); + } catch (_) {} + apply(); + setSyncStatus('Theme follows system', 'is-current'); + }; + + button.addEventListener('click', function () { + if (longPressed) { + longPressed = false; + return; + } + const current = storedTheme() || systemTheme(); + setTheme(current === 'dark' ? 'light' : 'dark'); + }); + button.addEventListener('pointerdown', function () { + longPressed = false; + window.clearTimeout(longPressTimer); + longPressTimer = window.setTimeout(function () { + longPressed = true; + clearTheme(); + }, 650); + }); + for (const eventName of ['pointerup', 'pointercancel', 'pointerleave']) { + button.addEventListener(eventName, function () { + window.clearTimeout(longPressTimer); + }); + } + if (media) { + media.addEventListener('change', apply); + } + apply(); +} + +function connectBgitEvents() { + if (!window.EventSource) return; + let source = null; + let reconnecting = false; + const connect = function () { + source = new EventSource('/events'); + source.onopen = function () { + if (reconnecting) { + reconnecting = false; + clearSyncStatus(); + } + }; + source.addEventListener('git', function () { + refreshRemoteState({refreshPullRequests: true}); + }); + source.addEventListener('whoami', function (event) { + try { + setWhoamiState(JSON.parse(event.data || 'null')); + } catch (_) {} + }); + source.addEventListener('assets', function () { + setSyncStatus('Web assets changed. Reloading…', 'is-stale'); + window.location.reload(); + }); + source.onerror = function () { + reconnecting = true; + setSyncStatus('Lost connection to bgit@' + window.location.host + '... reconnecting.', 'is-error'); + }; + }; + connect(); + window.addEventListener('beforeunload', function () { + if (source) source.close(); + }); +} + +async function refreshWhoamiState() { + try { + setWhoamiState(await fetchJSON('/api/me?refresh=1')); + } catch (_) {} +} + +function setWhoamiState(value) { + currentWhoami = value || null; + document.documentElement.dataset.bgitRole = currentWhoami && currentWhoami.role ? currentWhoami.role : ''; + applyCapabilityUI(); +} + +function hasCapability(name) { + if (!name) return true; + if (!currentWhoami || !currentWhoami.capabilities) return false; + return currentWhoami.capabilities[name] === true; +} + +function applyCapabilityUI() { + for (const el of document.querySelectorAll('[data-capability]')) { + const allowed = hasCapability(el.getAttribute('data-capability') || ''); + const disabledMessage = 'Your current broker role does not allow this action.'; + if (el.matches('button, input, select, textarea')) { + el.disabled = !allowed; + el.title = allowed ? '' : disabledMessage; + } else { + el.classList.toggle('is-capability-disabled', !allowed); + el.title = allowed ? '' : disabledMessage; + for (const control of el.querySelectorAll('button, input, select, textarea')) control.disabled = !allowed; + } + } +} + +async function fetchJSON(path) { + const response = await fetch(path, {headers: {'accept': 'application/json'}}); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +async function postJSON(path, body) { + const response = await fetch(path, { + method: 'POST', + headers: {'accept': 'application/json', 'content-type': 'application/json'}, + body: JSON.stringify(body || {}) + }); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +let webActionInFlight = false; + +function formValue(form, name) { + const field = form.elements[name]; + return field ? String(field.value || '').trim() : ''; +} + +function formChecked(form, name) { + const field = form.elements[name]; + return !!(field && field.checked); +} + +async function handleSettingsForm(form) { + const action = form.getAttribute('data-settings-form') || ''; + if (!hasCapability(form.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + const payload = {action}; + if (action === 'update-repo') { + payload.description = formValue(form, 'description'); + payload.default_branch = formValue(form, 'default_branch'); + payload.visibility = formValue(form, 'visibility') || 'private'; + payload.read_only = formChecked(form, 'read_only'); + payload.issues_enabled = formChecked(form, 'issues_enabled'); + } else if (action === 'add-member') { + payload.user = formValue(form, 'user'); + payload.role = formValue(form, 'role'); + if (!payload.user) { + setSyncStatus('Username is required.', 'is-stale'); + return; + } + } else if (action === 'transfer-owner') { + const ok = await confirmModal({title: 'Transfer ownership?', body: 'This creates a one-time command for the new owner to accept with their own SSH key.', confirm: 'Create command'}); + if (!ok) return; + } else if (action === 'repo-rename') { + payload.logical = formValue(form, 'logical'); + if (!payload.logical) { + setSyncStatus('New logical repository name is required.', 'is-stale'); + return; + } + const ok = await confirmModal({title: 'Rename repository?', body: 'Rename this logical repository to ' + payload.logical + '.', confirm: 'OK'}); + if (!ok) return; + } else if (action === 'repo-delete') { + const expected = form.querySelector('[data-confirm-repo]')?.getAttribute('data-confirm-repo') || ''; + const actual = formValue(form, 'confirm'); + if (!expected || actual !== expected) { + setSyncStatus('Type the repository name exactly to delete it.', 'is-stale'); + return; + } + const ok = await confirmModal({title: 'Delete repository?', body: 'This permanently deletes broker metadata, bucket contents, and the bucket for ' + expected + '.', confirm: 'Delete'}); + if (!ok) return; + } else if (action === 'protect-upsert') { + payload.ref = formValue(form, 'ref'); + payload.require_pr = formChecked(form, 'require_pr'); + payload.allow_overrides = formChecked(form, 'allow_overrides'); + if (!payload.ref) { + setSyncStatus('Branch or ref is required.', 'is-stale'); + return; + } + } + try { + setSettingsBusy(true); + const data = await postJSON('/api/actions/settings', payload); + const command = data && data.broker && (data.broker.accept_command || data.broker.cancel_command); + if (command) { + await confirmModal({title: action === 'add-member' ? 'Invite command' : 'Ownership transfer command', body: command, confirm: 'OK'}); + } + rememberSettingsStatus(settingsSuccessMessage(action, payload, form)); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setSettingsBusy(false); + } +} + +async function handleSettingsAction(button) { + const action = button.getAttribute('data-settings-action') || ''; + if (!hasCapability(button.closest('[data-capability]')?.getAttribute('data-capability') || button.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + const member = button.closest('[data-member-key]'); + const protection = button.closest('[data-protection-ref]'); + const payload = {action}; + let subject = ''; + let title = 'Apply settings change?'; + let body = 'This updates broker-managed repository settings.'; + if (member) { + payload.key = member.getAttribute('data-member-key') || ''; + subject = member.querySelector('strong')?.textContent || 'member'; + if (!payload.key) { + setSyncStatus('Member key is missing from the selected row.', 'is-stale'); + return; + } + if (action === 'remove-member') { + title = 'Remove member?'; + body = 'Remove ' + subject + ' from this repository.'; + } else if (action === 'suspend-member') { + title = 'Suspend member?'; + body = 'Suspend ' + subject + ' for this repository without removing the key.'; + } else if (action === 'unsuspend-member') { + title = 'Unsuspend member?'; + body = 'Restore access for ' + subject + ' on this repository.'; + } + } + if (protection) { + payload.ref = protection.getAttribute('data-protection-ref') || ''; + subject = payload.ref; + if (!payload.ref) { + setSyncStatus('Branch protection ref is missing from the selected row.', 'is-stale'); + return; + } + title = 'Remove branch protection?'; + body = 'Remove branch protection for ' + payload.ref + '.'; + } + const ok = await confirmModal({title, body, confirm: 'OK'}); + if (!ok) return; + try { + setSettingsBusy(true); + await postJSON('/api/actions/settings', payload); + rememberSettingsStatus(settingsSuccessMessage(action, Object.assign({}, payload, {subject}))); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setSettingsBusy(false); + } +} + +async function handleIssueForm(form) { + const action = form.getAttribute('data-issue-form') || ''; + const panel = form.closest('[data-issue-id]'); + const payload = {action}; + if (panel) payload.id = Number(panel.getAttribute('data-issue-id') || 0); + if (action === 'create') { + payload.title = formValue(form, 'title'); + payload.body = formValue(form, 'body'); + if (!payload.title) { + setSyncStatus('Issue title is required.', 'is-stale'); + return; + } + } else if (action === 'comment') { + payload.comment = formValue(form, 'comment'); + if (!payload.id || !payload.comment) { + setSyncStatus('Issue comment is required.', 'is-stale'); + return; + } + } + try { + await postJSON('/api/actions/issues', payload); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } +} + +async function handleIssueAction(button) { + const panel = button.closest('[data-issue-id]'); + const id = Number(panel?.getAttribute('data-issue-id') || 0); + const action = button.getAttribute('data-issue-action') || ''; + if (!id || !action) return; + try { + await postJSON('/api/actions/issues', {action, id}); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } +} + +function setSettingsBusy(busy) { + for (const el of document.querySelectorAll('[data-settings-root] button, [data-settings-root] input, [data-settings-root] textarea, [data-settings-root] select')) { + el.disabled = !!busy; + } + if (!busy) applyCapabilityUI(); +} + +function settingsSuccessMessage(action, payload, form) { + const subject = payload.subject || payload.user || payload.ref || form?.querySelector('input[name="user"]')?.value || ''; + if (action === 'update-repo') return 'Repository settings saved.'; + if (action === 'add-member') return 'Created invite for ' + subject + '.'; + if (action === 'transfer-owner') return 'Ownership transfer is pending.'; + if (action === 'repo-rename') return 'Repository renamed.'; + if (action === 'repo-delete') return 'Repository deleted.'; + if (action === 'suspend-member') return 'Suspended ' + subject + '.'; + if (action === 'unsuspend-member') return 'Unsuspended ' + subject + '.'; + if (action === 'remove-member') return 'Removed ' + subject + '.'; + if (action === 'protect-upsert') return 'Protected ' + subject + '.'; + if (action === 'protect-remove') return 'Removed protection for ' + subject + '.'; + return 'Settings updated.'; +} + +function rememberSettingsStatus(message) { + try { + window.sessionStorage.setItem('bgit.settingsStatus', message); + } catch (_) {} +} + +function restoreSettingsStatus() { + let message = ''; + try { + message = window.sessionStorage.getItem('bgit.settingsStatus') || ''; + window.sessionStorage.removeItem('bgit.settingsStatus'); + } catch (_) {} + if (message) setSyncStatus(message, 'is-current'); +} + +async function handleWebAction(action, trigger) { + if (webActionInFlight) return; + try { + if (action === 'stage') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + setWebActionsBusy(true, 'STAGING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/stage', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Staged ' + path + '.', 'is-current'); + return; + } + if (action === 'unstage') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + setWebActionsBusy(true, 'UNSTAGING'); + const data = await postJSON('/api/actions/unstage', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Unstaged ' + path + '.', 'is-current'); + return; + } + if (action === 'discard') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + const ok = await confirmModal({ + title: 'Checkout file?', + body: 'Discard local changes for ' + path + ' and restore it from the remote branch when available.', + confirm: 'OK' + }); + if (!ok) return; + setWebActionsBusy(true, 'CHECKING OUT'); + const data = await postJSON('/api/actions/discard', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Checked out ' + path + '.', 'is-current'); + return; + } + if (action === 'commit') { + const stagedFiles = currentWebState && Array.isArray(currentWebState.staged_files) ? currentWebState.staged_files : []; + const message = await promptModal({ + title: 'Commit staged changes', + body: 'Commit the staged changes on the current branch.', + files: stagedFiles, + inputLabel: 'Commit message', + confirm: 'Commit', + required: true + }); + if (!message) return; + setWebActionsBusy(true, 'COMMITTING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/commit', {message}); + currentWebState = data.state || null; + setSyncStatus('Committed local changes.', 'is-current'); + reloadLocalView(); + return; + } + if (action === 'push') { + setWebActionsBusy(true, 'PUSHING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/push', {}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + if (currentWebState && Number(currentWebState.ahead || 0) > 0) { + throw new Error('Push did not complete; local branch is still ahead of remote.'); + } + reconcileRemoteState(currentWebState); + setSyncStatus('Push confirmed.', 'is-current'); + return; + } + if (action === 'uncommit') { + const ok = await confirmModal({ + title: 'Uncommit local commits?', + body: 'Move unpushed commits back into staged changes. Nothing will be changed on the remote.', + confirm: 'Uncommit' + }); + if (!ok) return; + setWebActionsBusy(true, 'UNCOMMITTING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/uncommit', {}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Uncommitted local commits.', 'is-current'); + return; + } + if (action === 'pull') { + const ok = await confirmModal({ + title: 'Pull remote changes?', + body: 'Remote has commits that are not in your local branch. Pull them into this working tree?', + confirm: 'Pull' + }); + if (!ok) return; + setWebActionsBusy(true, 'PULLING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/pull', {}); + currentWebState = data.state || null; + setSyncStatus('Pulled remote changes.', 'is-current'); + reloadLocalView(); + } + } catch (err) { + setRemoteSyncStatus('error', compactError(err)); + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setWebActionsBusy(false); + } +} + +function confirmModal(options) { + return modalDialog(options).then(function (value) { return value === true; }); +} + +function promptModal(options) { + return modalDialog(Object.assign({}, options, {prompt: true})); +} + +async function showInlineDiff(trigger) { + const path = trigger.getAttribute('data-path') || ''; + const mode = trigger.getAttribute('data-mode') || 'worktree'; + const diffURL = trigger.getAttribute('data-diff-url') || (path ? '/api/diff?path=' + encodeURIComponent(path) + '&mode=' + encodeURIComponent(mode) : ''); + if (!diffURL) return; + const anchor = trigger.closest('[data-file-row]') || trigger.closest('[data-commit-row]') || trigger.closest('.pr-detail-header'); + if (!anchor) return; + const existing = anchor.nextElementSibling && anchor.nextElementSibling.matches('[data-inline-diff-row]') ? anchor.nextElementSibling : null; + if (existing) { + existing.remove(); + trigger.classList.remove('is-active'); + trigger.setAttribute('aria-expanded', 'false'); + return; + } + for (const open of document.querySelectorAll('[data-inline-diff-row]')) open.remove(); + for (const active of document.querySelectorAll('[data-web-diff].is-active')) { + active.classList.remove('is-active'); + active.setAttribute('aria-expanded', 'false'); + } + trigger.disabled = true; + try { + const data = await fetchJSON(diffURL); + const title = trigger.getAttribute('data-diff-title') || path || 'Diff'; + const subtitle = trigger.getAttribute('data-diff-subtitle') || (mode === 'staged' ? 'Staged changes' : 'Unstaged changes'); + const diffRow = inlineDiffElement(anchor, title, subtitle, data.html || visualDiffHTML(data.diff || ''), !!data.html); + anchor.insertAdjacentElement('afterend', diffRow); + trigger.classList.add('is-active'); + trigger.setAttribute('aria-expanded', 'true'); + } catch (err) { + setRemoteSyncStatus('error', compactError(err)); + setSyncStatus(compactError(err), 'is-stale'); + } finally { + trigger.disabled = false; + } +} + +function inlineDiffElement(anchor, title, subtitle, content, isHTML) { + let el; + let inner; + if (anchor.tagName === 'TR') { + el = document.createElement('tr'); + inner = '' + inlineDiffShellHTML(title, subtitle, content, isHTML) + ''; + } else if (anchor.tagName === 'LI') { + el = document.createElement('li'); + inner = inlineDiffShellHTML(title, subtitle, content, isHTML); + } else { + el = document.createElement('section'); + inner = inlineDiffShellHTML(title, subtitle, content, isHTML); + } + el.className = 'inline-diff-row'; + el.setAttribute('data-inline-diff-row', ''); + el.innerHTML = inner; + return el; +} + +function inlineDiffShellHTML(title, subtitle, content, isHTML) { + const body = isHTML ? String(content || '') : visualDiffHTML(content || ''); + return '
    ' + escapeHTML(title) + '' + escapeHTML(subtitle || '') + '
    ' + body + '
    '; +} + +function visualDiffHTML(diff) { + const files = parseUnifiedDiff(diff || ''); + if (!files.length) return '
    No diff available.
    '; + return '
    ' + files.map(function (file) { + return '
    ' + escapeHTML(file.path || 'Changed file') + '
    Before
    After
    ' + file.rows.map(diffRowHTML).join('') + '
    '; + }).join('') + '
    '; +} + +function parseUnifiedDiff(diff) { + const files = []; + let current = null; + let pendingDeletes = []; + let oldLine = 0; + let newLine = 0; + function flushDeletes() { + if (!current || !pendingDeletes.length) return; + for (const line of pendingDeletes) current.rows.push({kind: 'del', left: line.text, right: '', oldLine: line.oldLine, newLine: ''}); + pendingDeletes = []; + } + for (const raw of String(diff || '').split(/\r?\n/)) { + if (raw.startsWith('diff --git ')) { + flushDeletes(); + const match = raw.match(/^diff --git a\/(.+?) b\/(.+)$/); + current = {path: match ? match[2] : 'Changed file', rows: []}; + files.push(current); + continue; + } + if (!current && raw !== '') { + current = {path: 'Changed file', rows: []}; + files.push(current); + } + if (!current) continue; + if (raw.startsWith('+++ ') || raw.startsWith('--- ') || raw.startsWith('index ') || raw.startsWith('new file mode') || raw.startsWith('deleted file mode')) continue; + if (raw.startsWith('@@')) { + flushDeletes(); + const hunk = parseHunkStart(raw); + oldLine = hunk.oldLine; + newLine = hunk.newLine; + current.rows.push({kind: 'hunk', left: raw, right: raw, oldLine: '', newLine: ''}); + continue; + } + if (raw.startsWith('-')) { + pendingDeletes.push({text: raw.slice(1), oldLine: oldLine}); + oldLine += 1; + continue; + } + if (raw.startsWith('+')) { + const added = raw.slice(1); + if (pendingDeletes.length) { + const deleted = pendingDeletes.shift(); + current.rows.push({kind: 'change', left: deleted.text, right: added, oldLine: deleted.oldLine, newLine: newLine}); + } else { + current.rows.push({kind: 'add', left: '', right: added, oldLine: '', newLine: newLine}); + } + newLine += 1; + continue; + } + flushDeletes(); + if (raw === '\\ No newline at end of file') { + current.rows.push({kind: 'note', left: raw, right: raw, oldLine: '', newLine: ''}); + } else { + const text = raw.startsWith(' ') ? raw.slice(1) : raw; + current.rows.push({kind: 'same', left: text, right: text, oldLine: oldLine, newLine: newLine}); + oldLine += 1; + newLine += 1; + } + } + flushDeletes(); + for (const file of files) { + if (!file.rows.length) file.rows.push({kind: 'note', left: 'No textual changes.', right: 'No textual changes.', oldLine: '', newLine: ''}); + } + return files; +} + +function parseHunkStart(line) { + const match = String(line || '').match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + return { + oldLine: match ? Number(match[1]) : 0, + newLine: match ? Number(match[2]) : 0 + }; +} + +function diffRowHTML(row) { + if (row.kind === 'hunk' || row.kind === 'note') { + return '
    ' + escapeHTML(formatDiffDivider(row.left)) + '
    '; + } + if (row.kind === 'change') { + const segments = inlineChangedSegments(row.left, row.right); + return '
    ' + escapeHTML(row.oldLine || '') + '
    ' + segments.left + '
    ' + escapeHTML(row.newLine || '') + '
    ' + segments.right + '
    '; + } + return '
    ' + escapeHTML(row.oldLine || '') + '
    ' + diffCellHTML(row.left, row.kind === 'del' ? 'deleted' : 'same') + '
    ' + escapeHTML(row.newLine || '') + '
    ' + diffCellHTML(row.right, row.kind === 'add' ? 'added' : 'same') + '
    '; +} + +function revealDiffContext(button) { + const divider = button.closest('.visual-diff-divider'); + if (!divider) return; + const direction = button.getAttribute('data-diff-context'); + const hiddenRows = hiddenContextRowsForDivider(divider, direction); + const rows = direction === 'up' ? hiddenRows.slice(-20) : hiddenRows.slice(0, 20); + for (const row of rows) { + row.hidden = false; + row.removeAttribute('data-hidden-context'); + } + if (rows.length > 0) { + if (direction === 'up') { + rows[0].before(divider); + } else { + rows[rows.length - 1].after(divider); + } + } + if (hiddenContextRowsForDivider(divider, direction).length === 0) { + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + } +} + +function hiddenContextRowsForDivider(divider, direction) { + const rows = []; + if (direction === 'up') { + let node = divider.previousElementSibling; + while (node && !node.classList.contains('visual-diff-divider')) { + if (node.hasAttribute('data-hidden-context')) rows.push(node); + node = node.previousElementSibling; + } + rows.reverse(); + return rows; + } + let node = divider.nextElementSibling; + while (node && !node.classList.contains('visual-diff-divider')) { + if (node.hasAttribute('data-hidden-context')) rows.push(node); + node = node.nextElementSibling; + } + return rows; +} + +function formatDiffDivider(line) { + const match = String(line || '').match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!match) return line || ''; + const oldStart = Number(match[1]); + const oldCount = Number(match[2] || 1); + const newStart = Number(match[3]); + const newCount = Number(match[4] || 1); + return 'Lines ' + lineRangeLabel(oldStart, oldCount) + ' -> ' + lineRangeLabel(newStart, newCount); +} + +function lineRangeLabel(start, count) { + if (!count || count <= 1) return String(start); + return String(start) + '-' + String(start + count - 1); +} + +function diffCellHTML(text, kind) { + if (!text) return ''; + const value = escapeHTML(text); + if (kind === 'deleted' || kind === 'added') return '' + value + ''; + return value; +} + +function inlineChangedSegments(left, right) { + left = String(left || ''); + right = String(right || ''); + let prefix = 0; + while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < left.length - prefix && + suffix < right.length - prefix && + left[left.length - 1 - suffix] === right[right.length - 1 - suffix] + ) { + suffix += 1; + } + const oldEnd = left.length - suffix; + const newEnd = right.length - suffix; + return { + left: escapeHTML(left.slice(0, prefix)) + '' + escapeHTML(left.slice(prefix, oldEnd) || ' ') + '' + escapeHTML(left.slice(oldEnd)), + right: escapeHTML(right.slice(0, prefix)) + '' + escapeHTML(right.slice(prefix, newEnd) || ' ') + '' + escapeHTML(right.slice(newEnd)) + }; +} + +async function handlePullRequestAction(trigger) { + const panel = trigger.closest('[data-pr-id]'); + if (!panel) return; + const id = Number(panel.getAttribute('data-pr-id') || 0); + const action = trigger.getAttribute('data-pr-action') || ''; + const textarea = panel.querySelector('[data-pr-comment]'); + const deleteBranch = panel.querySelector('[data-pr-delete-branch]'); + const comment = textarea ? textarea.value.trim() : ''; + try { + let confirmed = true; + if (action === 'merge') { + confirmed = await confirmModal({ + title: 'Merge pull request?', + body: deleteBranch && deleteBranch.checked ? 'Merge this pull request and delete the source branch afterwards.' : 'Merge this pull request into the target branch.', + confirm: 'Merge' + }); + } else if (action === 'reject') { + confirmed = await confirmModal({ + title: 'Request changes?', + body: comment ? 'Submit this review as changes requested.' : 'Submit a changes requested review without a note.', + confirm: 'Request changes' + }); + } else if (action === 'close') { + confirmed = await confirmModal({ + title: 'Close pull request?', + body: 'Close this pull request without merging it.', + confirm: 'Close PR' + }); + } else if (action === 'reopen') { + confirmed = await confirmModal({ + title: 'Reopen pull request?', + body: 'Reopen this pull request so it can be reviewed and merged again.', + confirm: 'Reopen PR' + }); + } + if (!confirmed) return; + setPullRequestActionsBusy(panel, true, action); + const data = await postJSON('/api/actions/pr', { + id, + action, + comment, + delete_branch: !!(deleteBranch && deleteBranch.checked) + }); + if (Array.isArray(data.prs)) updatePullRequestUI(data.prs); + setSyncStatus('Pull request updated.', 'is-current'); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setPullRequestActionsBusy(panel, false); + } +} + +function showPullRequestReplyEditor(trigger) { + const panel = trigger.closest('[data-pr-id]'); + if (!panel) return; + const host = trigger.closest('.pr-inline-comment-body') || trigger.closest('.pr-reply') || trigger.closest('.pr-note'); + if (!host) return; + const existing = host.parentElement ? host.parentElement.querySelector('[data-draft-editor][data-draft-kind="reply"]') : null; + if (existing) existing.remove(); + const editor = document.createElement('div'); + editor.className = 'inline-draft-editor'; + if (host.parentElement && host.parentElement.classList.contains('pr-inline-after-context')) { + editor.classList.add('pr-inline-reply-editor'); + } + editor.setAttribute('data-draft-editor', ''); + editor.setAttribute('data-draft-kind', 'reply'); + editor.setAttribute('data-pr-id', panel.getAttribute('data-pr-id') || ''); + editor.setAttribute('data-target-note-id', trigger.getAttribute('data-target-note-id') || ''); + editor.setAttribute('data-target-comment-id', trigger.getAttribute('data-target-comment-id') || ''); + editor.innerHTML = inlineDraftEditorHTML('Reply'); + host.insertAdjacentElement('afterend', editor); + focusInlineDraft(editor); +} + +async function submitPullRequestReply(editor) { + const id = Number(editor.getAttribute('data-pr-id') || 0); + const textarea = editor.querySelector('[data-draft-text]'); + const text = textarea ? textarea.value.trim() : ''; + if (!text) return; + const targetNoteID = Number(editor.getAttribute('data-target-note-id') || 0); + const targetCommentID = Number(editor.getAttribute('data-target-comment-id') || 0); + try { + setInlineDraftBusy(editor, true); + const data = await postJSON('/api/actions/pr', { + id, + action: 'reply', + comment: text, + target_note_id: targetNoteID, + target_comment_id: targetCommentID + }); + if (Array.isArray(data.prs)) updatePullRequestUI(data.prs); + rememberPullRequestScrollTarget(findSubmittedReplyTarget(data, id, text) || fallbackReplyTarget(targetNoteID, targetCommentID)); + setSyncStatus('Reply added.', 'is-current'); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setInlineDraftBusy(editor, false); + } +} + +function fallbackReplyTarget(noteID, commentID) { + if (commentID) return 'pr-comment-' + commentID; + if (noteID) return 'pr-note-' + noteID; + return ''; +} + +function rememberPullRequestScrollTarget(targetID) { + if (!targetID) return; + try { + window.sessionStorage.setItem(prScrollTargetKey, targetID); + } catch (_) {} +} + +function restorePullRequestScrollTarget() { + let targetID = ''; + try { + targetID = window.sessionStorage.getItem(prScrollTargetKey) || ''; + if (targetID) window.sessionStorage.removeItem(prScrollTargetKey); + } catch (_) {} + if (!targetID) return; + window.setTimeout(function () { + const target = document.getElementById(targetID); + if (!target) return; + target.scrollIntoView({block: 'center', behavior: 'smooth'}); + target.classList.add('is-scroll-target'); + window.setTimeout(function () { target.classList.remove('is-scroll-target'); }, 1800); + }, 80); +} + +function findSubmittedReplyTarget(data, prID, body) { + const prs = Array.isArray(data && data.prs) ? data.prs : []; + const pr = prs.find(function (item) { return Number(item.id || 0) === Number(prID || 0); }); + if (!pr) return ''; + const normalizedBody = String(body || '').trim(); + let found = null; + for (const note of [].concat(pr.comments || [], pr.reviews || [])) { + for (const comment of collectPullRequestComments(note)) { + if (String(comment.body || '').trim() === normalizedBody) { + if (!found || Number(comment.id || 0) > Number(found.id || 0)) found = comment; + } + } + } + return found && found.id ? 'pr-comment-' + found.id : ''; +} + +function collectPullRequestComments(noteOrComment) { + const out = []; + function visit(comment) { + if (!comment) return; + out.push(comment); + for (const reply of comment.replies || []) visit(reply); + } + for (const comment of noteOrComment.comments || []) visit(comment); + for (const reply of noteOrComment.replies || []) visit(reply); + return out; +} + +function setPullRequestActionsBusy(panel, busy, activeAction) { + for (const button of panel.querySelectorAll('[data-pr-action]')) { + button.disabled = busy; + if (!button.dataset.label) button.dataset.label = button.textContent; + if (busy && button.getAttribute('data-pr-action') === activeAction) { + button.textContent = 'Working...'; + } else { + button.textContent = button.dataset.label; + } + } +} + +function showReviewDraftEditor(button) { + const filePanel = button.closest('[data-review-file]'); + const row = button.closest('.visual-diff-row'); + const host = row || filePanel; + if (!host) return; + const existing = host.parentElement ? host.parentElement.querySelector('[data-draft-editor][data-draft-kind="review-comment"]') : null; + if (existing) existing.remove(); + const editor = document.createElement('div'); + editor.className = row ? 'visual-diff-row review-draft-editor-row' : 'inline-draft-editor'; + editor.setAttribute('data-draft-editor', ''); + editor.setAttribute('data-draft-kind', 'review-comment'); + const target = reviewDraftTargetFromButton(button); + editor.setAttribute('data-review-kind', target.kind); + editor.setAttribute('data-review-file', target.file); + editor.setAttribute('data-review-line', String(target.line || 0)); + editor.innerHTML = row ? '
    ' + inlineDraftEditorHTML('Comment') + '
    ' : inlineDraftEditorHTML('Comment'); + if (row) row.insertAdjacentElement('afterend', editor); + else filePanel.querySelector('.diff-header')?.insertAdjacentElement('afterend', editor); + editor._reviewTrigger = button; + focusInlineDraft(editor); + if (!restoringReviewDraft) saveReviewDraftState(); +} + +function addReviewDraftComment(button, text) { + const comment = reviewCommentFromButton(button, text); + reviewDraftComments.push(comment); + renderReviewDraftComment(button, comment); + updateReviewDraftState(); + saveReviewDraftState(); +} + +function reviewCommentFromButton(button, text) { + const filePanel = button.closest('[data-review-file]'); + const row = button.closest('.visual-diff-row'); + const file = button.getAttribute('data-file') || (filePanel ? filePanel.getAttribute('data-review-file') : ''); + const line = Number(button.getAttribute('data-line') || (row ? row.querySelector('[data-new-line]')?.getAttribute('data-new-line') : 0) || 0); + return { + body: text, + file, + kind: button.hasAttribute('data-review-comment-file') ? 'file' : 'line', + side: 'new', + hunk: row ? row.getAttribute('data-hunk') || '' : '', + hunk_index: Number(row ? row.getAttribute('data-hunk-index') || 0 : 0), + old_start: Number(row ? row.getAttribute('data-old-start') || 0 : 0), + new_start: Number(row ? row.getAttribute('data-new-start') || 0 : 0), + offset: Number(row ? row.getAttribute('data-offset') || 0 : 0), + line, + line_text: row ? (row.querySelector('pre[data-new-line]')?.innerText || '').replace(/šŸ’¬\s*$/, '').trimEnd() : '' + }; +} + +function submitInlineDraft(button) { + const editor = button.closest('[data-draft-editor]'); + if (!editor) return; + const textarea = editor.querySelector('[data-draft-text]'); + const text = textarea ? textarea.value.trim() : ''; + if (!text) { + editor.classList.add('has-error'); + if (textarea) textarea.focus(); + return; + } + editor.classList.remove('has-error'); + const kind = editor.getAttribute('data-draft-kind') || ''; + if (kind === 'reply') { + submitPullRequestReply(editor); + return; + } + if (kind === 'review-comment') { + const trigger = editor._reviewTrigger || findReviewDraftButton({ + kind: editor.getAttribute('data-review-kind') || 'line', + file: editor.getAttribute('data-review-file') || '', + line: Number(editor.getAttribute('data-review-line') || 0) + }); + if (!trigger) return; + addReviewDraftComment(trigger, text); + editor.remove(); + saveReviewDraftState(); + } +} + +function inlineDraftEditorHTML(label) { + return '
    Comment is required.
    '; +} + +function focusInlineDraft(editor) { + window.setTimeout(function () { + const textarea = editor.querySelector('[data-draft-text]'); + if (textarea) textarea.focus(); + }, 0); +} + +function setInlineDraftBusy(editor, busy) { + for (const button of editor.querySelectorAll('button')) button.disabled = busy; + const textarea = editor.querySelector('[data-draft-text]'); + if (textarea) textarea.disabled = busy; +} + +function renderReviewDraftComment(button, comment) { + const row = button.closest('.visual-diff-row'); + const host = row || button.closest('[data-review-file]'); + if (!host) return; + const html = '
    You commented' + (comment.kind === 'line' && comment.line ? ' line ' + escapeHTML(comment.line) + '' : '') + '
    ' + escapeHTML(comment.body) + '
    '; + if (row) row.insertAdjacentHTML('afterend', '
    ' + html + '
    '); + else host.querySelector('.diff-header')?.insertAdjacentHTML('afterend', html); +} + +function updateReviewDraftState() { + const form = document.querySelector('[data-pr-review-submit]'); + if (!form) return; + form.classList.toggle('has-drafts', reviewDraftComments.length > 0); +} + +async function submitReviewDraft(button) { + const form = button.closest('[data-pr-review-submit]'); + if (!form) return; + const id = Number(form.getAttribute('data-pr-id') || 0); + const note = form.querySelector('[data-pr-review-note]'); + const action = button.getAttribute('data-pr-review-action') || 'comment'; + const mapped = action === 'approve' ? 'approve' : action === 'reject' ? 'reject' : 'review-comment'; + if (!reviewDraftComments.length && !String(note ? note.value : '').trim() && mapped === 'review-comment') { + setSyncStatus('Add a review note or at least one inline comment.', 'is-stale'); + return; + } + button.disabled = true; + try { + const data = await postJSON('/api/actions/pr', { + id, + action: mapped, + comment: note ? note.value.trim() : '', + comments: reviewDraftComments + }); + reviewDraftComments.splice(0, reviewDraftComments.length); + clearStoredReviewDraft(id); + updatePullRequestUI(data.prs || []); + window.location.href = '/prs/' + encodeURIComponent(String(id)); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + button.disabled = false; + } +} + +function currentReviewID() { + const form = document.querySelector('[data-pr-review-submit]'); + if (form) return Number(form.getAttribute('data-pr-id') || 0); + const review = document.querySelector('[data-pr-review-diff]'); + return review ? Number(review.getAttribute('data-pr-id') || 0) : 0; +} + +function reviewDraftStorageKey(id) { + return id ? 'bgit.reviewDraft.' + id : ''; +} + +function saveReviewDraftState() { + if (restoringReviewDraft) return; + const id = currentReviewID(); + const key = reviewDraftStorageKey(id); + if (!key) return; + const note = document.querySelector('[data-pr-review-note]'); + const editors = Array.from(document.querySelectorAll('[data-draft-editor][data-draft-kind="review-comment"]')).map(function (editor) { + const textarea = editor.querySelector('[data-draft-text]'); + return { + kind: editor.getAttribute('data-review-kind') || 'line', + file: editor.getAttribute('data-review-file') || '', + line: Number(editor.getAttribute('data-review-line') || 0), + text: textarea ? textarea.value : '' + }; + }).filter(function (editor) { return editor.file || editor.text; }); + const state = { + note: note ? note.value : '', + comments: reviewDraftComments, + editors + }; + if (!state.note && !state.comments.length && !state.editors.length) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(state)); +} + +function restoreReviewDraftState() { + const id = currentReviewID(); + const key = reviewDraftStorageKey(id); + if (!key) return; + let state = null; + try { + state = JSON.parse(window.localStorage.getItem(key) || 'null'); + } catch (_) { + state = null; + } + if (!state) return; + restoringReviewDraft = true; + const note = document.querySelector('[data-pr-review-note]'); + if (note && typeof state.note === 'string') note.value = state.note; + for (const comment of Array.isArray(state.comments) ? state.comments : []) { + const button = findReviewDraftButton(comment); + if (!button) continue; + reviewDraftComments.push(comment); + renderReviewDraftComment(button, comment); + } + for (const draft of Array.isArray(state.editors) ? state.editors : []) { + const button = findReviewDraftButton(draft); + if (!button) continue; + showReviewDraftEditor(button); + const editor = document.querySelector('[data-draft-editor][data-review-file="' + cssEscape(draft.file || '') + '"][data-review-line="' + String(Number(draft.line || 0)) + '"]'); + const textarea = editor ? editor.querySelector('[data-draft-text]') : null; + if (textarea) textarea.value = draft.text || ''; + } + restoringReviewDraft = false; + updateReviewDraftState(); + saveReviewDraftState(); +} + +function clearStoredReviewDraft(id) { + const key = reviewDraftStorageKey(id); + if (key) window.localStorage.removeItem(key); +} + +function reviewDraftTargetFromButton(button) { + const filePanel = button.closest('[data-review-file]'); + return { + kind: button.hasAttribute('data-review-comment-file') ? 'file' : 'line', + file: button.getAttribute('data-file') || (filePanel ? filePanel.getAttribute('data-review-file') : ''), + line: Number(button.getAttribute('data-line') || 0) + }; +} + +function findReviewDraftButton(target) { + const file = target.file || ''; + const line = Number(target.line || 0); + if ((target.kind || '') === 'file') { + return document.querySelector('[data-review-comment-file="' + cssEscape(file) + '"]'); + } + return document.querySelector('[data-review-comment-line][data-file="' + cssEscape(file) + '"][data-line="' + String(line) + '"]'); +} + +function cssEscape(value) { + if (window.CSS && window.CSS.escape) return window.CSS.escape(String(value)); + return String(value).replace(/["\\]/g, '\\$&'); +} + +function modalDialog(options) { + return new Promise(function (resolve) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + const files = Array.isArray(options.files) ? options.files : []; + const fileListHTML = files.length ? '' : ''; + const fieldHTML = options.multiline ? '' : ''; + const inputHTML = options.prompt ? '' : ''; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + const input = overlay.querySelector('[data-modal-input]'); + const error = overlay.querySelector('[data-modal-error]'); + const close = function (value) { + overlay.remove(); + resolve(value); + }; + overlay.querySelector('[data-modal-cancel]').addEventListener('click', function () { close(false); }); + overlay.querySelector('[data-modal-confirm]').addEventListener('click', function () { + if (!input) { + close(true); + return; + } + const value = input.value.trim(); + if (options.required && !value) { + if (error) { + error.textContent = (options.inputLabel || 'Value') + ' is required.'; + error.hidden = false; + } + input.focus(); + return; + } + close(value); + }); + overlay.addEventListener('click', function (event) { + if (event.target === overlay) close(false); + }); + overlay.addEventListener('keydown', function (event) { + if (event.key === 'Escape') close(false); + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + overlay.querySelector('[data-modal-confirm]').click(); + } + }); + window.setTimeout(function () { + (input || overlay.querySelector('[data-modal-confirm]')).focus(); + }, 0); + }); +} + +async function hydrateRefs() { + const select = document.querySelector('[data-ref-selector]'); + if (!select) return; + const current = new URL(window.location.href).searchParams.get('ref') || select.value; + try { + const data = await fetchJSON('/api/refs'); + if (!Array.isArray(data.refs) || data.refs.length === 0) return; + select.textContent = ''; + let currentGroup = ''; + let group = null; + for (const ref of data.refs) { + if (ref.kind !== currentGroup) { + currentGroup = ref.kind; + group = document.createElement('optgroup'); + group.label = currentGroup; + select.appendChild(group); + } + const option = document.createElement('option'); + option.value = ref.full_name; + option.textContent = ref.name; + if (ref.full_name === current) option.selected = true; + group.appendChild(option); + } + } catch (_) { + // Server-rendered options remain usable if the JSON API is unavailable. + } +} + +async function refreshRemoteState(options) { + options = options || {}; + if (remoteRefreshInFlight) return; + remoteRefreshInFlight = true; + setRemoteRefreshSpinning(true); + if (!remoteSyncInitialized) { + setRemoteSyncStatus('syncing', 'Synchronising'); + } + try { + const ref = currentSelectedRef(); + const data = await fetchJSON('/api/state' + (ref ? '?ref=' + encodeURIComponent(ref) : '')); + currentWebState = data; + applyRepositoryState(data); + await refreshPullRequests(!!options.refreshPullRequests); + reconcileRemoteState(data); + } catch (err) { + remoteSyncInitialized = true; + setRemoteSyncStatus('error', compactError(err)); + } finally { + remoteRefreshInFlight = false; + setRemoteRefreshSpinning(false); + } +} + +async function refreshPullRequests(refresh) { + const tab = document.querySelector('[data-pr-tab]'); + const list = document.querySelector('[data-pr-list]'); + if (!tab && !list) return; + try { + const data = await fetchJSON('/api/prs' + (refresh ? '?refresh=1' : '')); + updatePullRequestUI(Array.isArray(data.prs) ? data.prs : []); + } catch (_) { + // Lack of PR visibility should not affect repository freshness. + } +} + +function currentSelectedRef() { + const urlRef = new URL(window.location.href).searchParams.get('ref'); + if (urlRef) return urlRef; + const selector = document.querySelector('[data-ref-selector]'); + return selector ? selector.value : ''; +} + +function updatePullRequestUI(prs) { + const tab = document.querySelector('[data-pr-tab]'); + if (tab) tab.hidden = prs.length === 0; + const count = document.querySelector('[data-pr-tab-count]'); + if (count) { + count.textContent = String(prs.length); + } + const list = document.querySelector('[data-pr-list]'); + if (list) { + list.innerHTML = pullRequestListHTML(prs); + } +} + +function pullRequestListHTML(prs) { + if (!prs.length) return '
    No pull requests found.
    '; + return '
      ' + prs.map(function (pr) { + const approvals = Number(pr.approvals || 0); + const approvalText = approvals > 0 ? '' + approvals + ' approval' + (approvals === 1 ? '' : 's') + '' : ''; + const id = escapeHTML(String(pr.id || '')); + const url = '/prs/' + id; + return '
    • ' + escapeHTML(shortRefName(pr.source || '')) + ' → ' + escapeHTML(shortRefName(pr.target || '')) + '
      ' + escapeHTML(pr.status || 'open') + '' + approvalText + '
    • '; + }).join('') + '
    '; +} + +function shortRefName(ref) { + return String(ref || '').replace(/^refs\/heads\//, '').replace(/^refs\/tags\//, ''); +} + +function escapeHTML(value) { + return String(value).replace(/[&<>"']/g, function (ch) { + return {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[ch]; + }); +} + +function reconcileRemoteState(data) { + remoteSyncInitialized = true; + if (data && data.fetch_error) { + setRemoteSyncStatus('error', compactError({message: data.fetch_error})); + return; + } + if (data && Number(data.behind || 0) > 0) { + setRemoteSyncStatus('behind', 'NOT PULLED'); + return; + } + if (data && Number(data.ahead || 0) > 0) { + setRemoteSyncStatus('ahead', 'NOT PUSHED'); + return; + } + if (data && Array.isArray(data.unstaged_files) && data.unstaged_files.length > 0) { + setRemoteSyncStatus('dirty', 'UNSTAGED'); + return; + } + if (data && Array.isArray(data.untracked_files) && data.untracked_files.length > 0) { + setRemoteSyncStatus('dirty', 'UNTRACKED'); + return; + } + if (data && data.dirty) { + setRemoteSyncStatus('dirty', 'UNCOMMITTED'); + return; + } + setRemoteSyncStatus('current', 'SYNCHED'); +} + +function applyRepositoryState(state) { + clearStateMarkers(); + if (!state) return; + const staged = new Set((state.staged_files || []).map(pathKey)); + const unstaged = new Set((state.unstaged_files || []).map(pathKey)); + const untracked = new Set((state.untracked_files || []).map(pathKey)); + const unpushed = new Set((state.unpushed_files || []).map(pathKey)); + const unpulled = new Set((state.unpulled_files || []).map(pathKey)); + for (const row of document.querySelectorAll('[data-file-row]')) { + const path = pathKey(row.getAttribute('data-file-path') || ''); + if (!path || path === '..') continue; + if (matchesStatePath(path, untracked)) addFileState(row, 'UNTRACKED', 'dirty', 'stage', path); + if (matchesStatePath(path, unstaged)) addFileState(row, 'UNSTAGED', 'dirty', 'stage', path); + if (matchesStatePath(path, staged)) addFileState(row, 'UNCOMMITTED', 'dirty', 'unstage', path); + if (matchesStatePath(path, unpushed)) addFileState(row, 'NOT PUSHED', 'ahead'); + if (matchesStatePath(path, unpulled)) addFileState(row, 'NOT PULLED', 'behind'); + } + addSyntheticFileRows(untracked, 'UNTRACKED', 'dirty'); + addSyntheticFileRows(staged, 'UNCOMMITTED', 'dirty'); + updateRepoActionButtons(state); + markCommits(state.unpushed_commits || [], 'NOT PUSHED', 'ahead'); + markCommits(state.unpulled_commits || [], 'NOT PULLED', 'behind'); +} + +function clearStateMarkers() { + for (const el of document.querySelectorAll('[data-state-marker]')) el.remove(); + for (const row of document.querySelectorAll('.is-state-dirty,.is-state-ahead,.is-state-behind')) { + row.classList.remove('is-state-dirty', 'is-state-ahead', 'is-state-behind'); + } + updateRepoActionButtons(null); +} + +function pathKey(path) { + return String(path || '').replace(/^\/+/, ''); +} + +function matchesStatePath(rowPath, statePaths) { + if (statePaths.has(rowPath)) return true; + return Array.from(statePaths).some(function (path) { + return path.startsWith(rowPath + '/'); + }); +} + +function addSyntheticFileRows(paths, label, kind) { + const table = document.querySelector('[data-file-list]'); + if (!table) return; + const current = currentTreePath(); + const existing = new Set(Array.from(document.querySelectorAll('[data-file-row]')).map(function (row) { + return pathKey(row.getAttribute('data-file-path') || ''); + })); + for (const path of paths) { + if (!path || existing.has(path)) continue; + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (parent !== current) continue; + const name = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path; + const row = document.createElement('tr'); + row.className = 'is-state-' + kind; + row.setAttribute('data-state-marker', 'true'); + row.setAttribute('data-file-row', ''); + row.setAttribute('data-file-name', name.toLowerCase()); + row.setAttribute('data-file-path', path); + row.innerHTML = 'file' + escapeHTML(name) + '' + stateMarkerHTML(label, kind, stateActionForKind(kind, path)) + 'local'; + table.appendChild(row); + } +} + +function currentTreePath() { + const path = window.location.pathname; + if (!path.startsWith('/tree/')) return ''; + return pathKey(decodeURIComponent(path.slice('/tree/'.length))); +} + +function addFileState(row, label, kind, actionKind, path) { + row.classList.add('is-state-' + kind); + const target = row.querySelector('[data-file-state]') || row.children[1]; + if (!target) return; + const actions = actionKind ? stateActionForKind(actionKind, path) : stateActionForKind(kind); + target.insertAdjacentHTML('beforeend', stateMarkerHTML(label, kind, actions)); +} + +function markCommits(commits, label, kind) { + const hashes = new Map(); + for (const commit of commits) { + if (commit.hash) hashes.set(commit.hash, commit); + } + for (const row of document.querySelectorAll('[data-commit-row]')) { + const hash = row.getAttribute('data-commit-hash') || ''; + if (!hashes.has(hash)) continue; + row.classList.add('is-state-' + kind); + const target = row.querySelector('[data-commit-state]') || row.firstElementChild; + if (target) target.insertAdjacentHTML('beforeend', stateMarkerHTML(label, kind, '')); + hashes.delete(hash); + } + const list = document.querySelector('.commits'); + if (!list || kind !== 'behind') return; + const missing = Array.from(hashes.values()).reverse(); + for (const commit of missing) { + const li = document.createElement('li'); + li.className = 'is-state-behind'; + li.setAttribute('data-state-marker', 'true'); + const commitURL = '/commits?commit=' + encodeURIComponent(commit.hash || ''); + li.setAttribute('data-commit-row', 'true'); + li.setAttribute('data-commit-hash', commit.hash || ''); + li.setAttribute('data-commit-href', commitURL); + li.innerHTML = '
    ' + escapeHTML(commit.subject || commit.short_hash || '') + '' + stateMarkerHTML(label, kind, '') + '
    ' + escapeHTML(commit.author || '') + ' authored remotely
    '; + list.insertBefore(li, list.firstChild); + } +} + +function stateActionForKind(kind, path) { + if (kind === 'stage') return diffActionHTML(path, 'worktree') + ''; + if (kind === 'unstage') return diffActionHTML(path, 'staged') + ''; + if (kind === 'commit') return ''; + return ''; +} + +function diffActionHTML(path, mode) { + return ''; +} + +function diffIconSVG() { + return ''; +} + +function updateRepoActionButtons(state) { + const control = document.querySelector('.repo-action-control'); + if (!control || control.getAttribute('data-code-actions') !== 'true') { + setRepoActionButton('[data-repo-commit]', 0, 'COMMIT'); + setRepoActionButton('[data-repo-push]', 0, 'PUSH'); + setRepoActionButton('[data-repo-pull]', 0, 'PULL'); + setRepoActionButton('[data-repo-uncommit]', 0, 'UNCOMMIT'); + return; + } + const stagedCount = state && Array.isArray(state.staged_files) ? state.staged_files.length : 0; + const aheadCount = state ? Number(state.ahead || 0) : 0; + const behindCount = state ? Number(state.behind || 0) : 0; + setRepoActionButton('[data-repo-commit]', stagedCount, 'COMMIT'); + setRepoActionButton('[data-repo-push]', aheadCount, 'PUSH'); + setRepoActionButton('[data-repo-pull]', behindCount, 'PULL'); + setRepoActionButton('[data-repo-uncommit]', aheadCount, 'UNCOMMIT'); + applyCapabilityUI(); +} + +function setRepoActionButton(selector, count, label) { + const button = document.querySelector(selector); + if (!button) return; + button.hidden = Number(count || 0) <= 0; + button.dataset.actionLabel = label; + button.textContent = label; +} + +function setWebActionsBusy(busy, activeLabel) { + webActionInFlight = busy; + for (const button of document.querySelectorAll('[data-web-action]')) { + button.disabled = busy; + if (button.matches('.repo-action-button')) { + if (busy && activeLabel && !button.hidden) { + button.textContent = activeLabel; + } else if (!busy && button.dataset.actionLabel) { + button.textContent = button.dataset.actionLabel; + } + } + } + applyCapabilityUI(); +} + +function stateMarkerHTML(label, kind, action) { + return '' + escapeHTML(label) + '' + (action || '') + ''; +} + +function setRemoteSyncStatus(state, text) { + const badge = document.querySelector('[data-remote-sync-badge]'); + const button = document.querySelector('[data-remote-refresh]'); + if (!badge || !button) return; + badge.textContent = text; + badge.title = text; + badge.className = 'remote-badge is-' + state; + const syncing = state === 'syncing'; + button.disabled = syncing; + button.classList.toggle('is-spinning', syncing); + button.classList.toggle('is-current', state === 'current'); +} + +function setRemoteRefreshSpinning(spinning) { + const button = document.querySelector('[data-remote-refresh]'); + if (!button) return; + button.disabled = spinning; + button.classList.toggle('is-spinning', spinning); + if (spinning) button.classList.remove('is-current'); +} + +function compactError(err) { + let text = err && err.message ? err.message : 'Remote check failed'; + text = text.replace(/\s+/g, ' ').trim(); + if (!text) text = 'Remote check failed'; + if (text.length > 80) text = text.slice(0, 77) + '...'; + return text; +} + +function reloadLocalView() { + const url = new URL(window.location.href); + url.searchParams.delete('_remote'); + url.searchParams.set('_ts', String(Date.now())); + window.location.replace(url.toString()); +} + +function remoteHeadHash(data) { + if (data.commit && data.commit.hash) return data.commit.hash; + if (data.head && data.head.hash) return data.head.hash; + if (Array.isArray(data.commits) && data.commits[0] && data.commits[0].hash) return data.commits[0].hash; + return ''; +} + +function setSyncStatus(text, cls) { + const el = document.querySelector('[data-sync-status]'); + if (!el) return; + window.clearTimeout(setSyncStatus.timer); + el.textContent = text; + el.className = 'sync-status is-visible ' + (cls || ''); + if (cls === 'is-current') { + setSyncStatus.timer = window.setTimeout(function () { + el.classList.remove('is-visible'); + }, 1800); + } +} + +function clearSyncStatus() { + const el = document.querySelector('[data-sync-status]'); + if (!el) return; + window.clearTimeout(setSyncStatus.timer); + el.classList.remove('is-visible'); +} + +function query(values) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value) params.set(key, value); + } + const text = params.toString(); + return text ? '?' + text : ''; +} diff --git a/www/bgit-mark.png b/www/bgit-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..5d887ea86e170b4bec2ac83daaf3961e07866de1 GIT binary patch literal 10749 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 + + + + + {{TITLE}} + + + + + +
    Checking remote…
    +{{BODY}} + + + + From 0d88c9c34045922e54083b4df8affc7ff39ccd78 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 14:22:09 +0200 Subject: [PATCH 05/17] fix Windows home and fake CLI helpers --- README.md | 2 + auth_capabilities_test.go | 4 +- broker_commands_test.go | 4 +- global_config_test.go | 2 +- main_test.go | 215 ++++++++++++++++++++++---------------- setup_test.go | 8 +- 6 files changed, 137 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 2a87ffb..ca1bec9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ clone, fetch, pull, push, review, or web browsing flows. Direct bucket access still exists under `bgit direct` for recovery, migration, and low-level inspection. It is not the normal user workflow. +![BucketGit serverless architecture](architecture/bucketgit-serverless-architecture.png) + ## Quickstart Set up BucketGit for one or more cloud profiles: diff --git a/auth_capabilities_test.go b/auth_capabilities_test.go index 0ec5760..cbe246f 100644 --- a/auth_capabilities_test.go +++ b/auth_capabilities_test.go @@ -13,7 +13,7 @@ import ( func TestWhoamiCommandWritesGlobalCache(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/auth/status" { t.Fatalf("path = %s", r.URL.Path) @@ -82,7 +82,7 @@ func TestRepoMembershipWarningsShowAmbiguousKeys(t *testing.T) { func TestExplicitProfileSelectionAppliesToRepositoryDiscovery(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) path := filepath.Join(home, ".bgit", "config.yaml") if err := writeGlobalConfig(path, globalConfig{ Version: globalConfigVersion, diff --git a/broker_commands_test.go b/broker_commands_test.go index 1c36f4b..06b9f3d 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -219,7 +219,7 @@ func TestBrokerProfileBareNameWithRegionSelectsProfile(t *testing.T) { func TestExplicitBrokerProfileSelectionUsesRegionForDataPlaneCommand(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) configPath := filepath.Join(home, ".bgit", "config.yaml") if err := writeGlobalConfig(configPath, globalConfig{ Version: globalConfigVersion, @@ -247,7 +247,7 @@ func TestExplicitBrokerProfileSelectionUsesRegionForDataPlaneCommand(t *testing. func TestExplicitBrokerProfileSelectionRejectsAmbiguousDataPlaneProfile(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) configPath := filepath.Join(home, ".bgit", "config.yaml") if err := writeGlobalConfig(configPath, globalConfig{ Version: globalConfigVersion, diff --git a/global_config_test.go b/global_config_test.go index ef92a0e..711b306 100644 --- a/global_config_test.go +++ b/global_config_test.go @@ -88,7 +88,7 @@ func TestGlobalConfigRoundTrip(t *testing.T) { func TestDefaultGlobalConfigPathUsesYAML(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) got, err := defaultGlobalConfigPath() if err != nil { t.Fatal(err) diff --git a/main_test.go b/main_test.go index 62fa3d4..d571dd2 100644 --- a/main_test.go +++ b/main_test.go @@ -52,6 +52,18 @@ func TestParseGlobalFlags(t *testing.T) { } } +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", home) + if volume := filepath.VolumeName(home); volume != "" { + t.Setenv("HOMEDRIVE", volume) + t.Setenv("HOMEPATH", strings.TrimPrefix(home, volume)) + } + } +} + func TestParseGlobalBucketURLInfersProviderAndPrefix(t *testing.T) { cfg, rest, err := parseGlobalFlags([]string{ "admin", @@ -1082,100 +1094,131 @@ type fakeCLIAction struct { onlyIfFile string } +type fakeCLIActionJSON struct { + Match string `json:"match,omitempty"` + Stdout string `json:"stdout,omitempty"` + MissingStdout string `json:"missing_stdout,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Touch string `json:"touch,omitempty"` + RequireFile string `json:"require_file,omitempty"` + OnlyIfFile string `json:"only_if_file,omitempty"` +} + +func (a fakeCLIAction) MarshalJSON() ([]byte, error) { + return json.Marshal(fakeCLIActionJSON{ + Match: a.match, + Stdout: a.stdout, + MissingStdout: a.missingStdout, + ExitCode: a.exitCode, + Touch: a.touch, + RequireFile: a.requireFile, + OnlyIfFile: a.onlyIfFile, + }) +} + +func (a *fakeCLIAction) UnmarshalJSON(data []byte) error { + var raw fakeCLIActionJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *a = fakeCLIAction{ + match: raw.Match, + stdout: raw.Stdout, + missingStdout: raw.MissingStdout, + exitCode: raw.ExitCode, + touch: raw.Touch, + requireFile: raw.RequireFile, + onlyIfFile: raw.OnlyIfFile, + } + return nil +} + func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { t.Helper() path := filepath.Join(dir, name) if runtime.GOOS == "windows" { - path += ".bat" + path += ".exe" } - var script strings.Builder - if runtime.GOOS == "windows" { - script.WriteString("@echo off\r\n") - script.WriteString("set ARGS=%*\r\n") - for _, action := range actions { - finalExitCode := fakeCLIFinalExitCode(action) - script.WriteString("echo %ARGS% | findstr /C:\"") - script.WriteString(escapeBatch(action.match)) - script.WriteString("\" >nul\r\n") - script.WriteString("if not errorlevel 1 (\r\n") - if action.requireFile != "" { - script.WriteString(" if not exist \"") - script.WriteString(escapeBatch(action.requireFile)) - script.WriteString("\" (\r\n") - if action.missingStdout != "" { - script.WriteString(" echo ") - script.WriteString(action.missingStdout) - script.WriteString("\r\n") - } - script.WriteString(" exit /b ") - script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString("\r\n )\r\n") - } - if action.touch != "" { - script.WriteString(" type nul > \"") - script.WriteString(escapeBatch(action.touch)) - script.WriteString("\"\r\n") - } - if action.stdout != "" { - script.WriteString(" echo ") - script.WriteString(action.stdout) - script.WriteString("\r\n") - } - script.WriteString(" exit /b ") - script.WriteString(strconv.Itoa(finalExitCode)) - script.WriteString("\r\n)\r\n") - } - script.WriteString("exit /b 1\r\n") - } else { - script.WriteString("#!/bin/sh\n") - script.WriteString("ARGS=\"$*\"\n") - for _, action := range actions { - finalExitCode := fakeCLIFinalExitCode(action) - script.WriteString("case \"$ARGS\" in *\"") - script.WriteString(strings.ReplaceAll(action.match, `"`, `\"`)) - script.WriteString("\"*) ") - if action.onlyIfFile != "" { - script.WriteString("if [ -f '") - script.WriteString(strings.ReplaceAll(action.onlyIfFile, `'`, `'\''`)) - script.WriteString("' ]; then ") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatal(err) + } + actionsData, err := json.Marshal(actions) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".json", actionsData, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestMain(m *testing.M) { + if path, ok := fakeCLIActionPath(); ok { + os.Exit(runFakeCLI(path, os.Args[1:])) + } + os.Exit(m.Run()) +} + +func fakeCLIActionPath() (string, bool) { + exe, err := os.Executable() + if err != nil { + return "", false + } + path := exe + ".json" + if _, err := os.Stat(path); err == nil { + return path, true + } + return "", false +} + +func runFakeCLI(path string, args []string) int { + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + var actions []fakeCLIAction + if err := json.Unmarshal(data, &actions); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + joined := strings.Join(args, " ") + for _, action := range actions { + if !strings.Contains(joined, action.match) { + continue + } + if action.onlyIfFile != "" { + if _, err := os.Stat(action.onlyIfFile); err != nil { + continue } - if action.requireFile != "" { - script.WriteString("[ -f '") - script.WriteString(strings.ReplaceAll(action.requireFile, `'`, `'\''`)) - script.WriteString("' ] || { ") + } + if action.requireFile != "" { + if _, err := os.Stat(action.requireFile); err != nil { if action.missingStdout != "" { - script.WriteString("printf '%s\\n' '") - script.WriteString(strings.ReplaceAll(action.missingStdout, `'`, `'\''`)) - script.WriteString("'") - script.WriteString(" ; ") + fmt.Fprintln(os.Stdout, action.missingStdout) } - script.WriteString("exit ") - script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString(" ; } ; ") - } - if action.touch != "" { - script.WriteString("touch '") - script.WriteString(strings.ReplaceAll(action.touch, `'`, `'\''`)) - script.WriteString("' ; ") - } - if action.stdout != "" { - script.WriteString("printf '%s\\n' '") - script.WriteString(strings.ReplaceAll(action.stdout, `'`, `'\''`)) - script.WriteString("'") - script.WriteString(" ; ") + return firstNonZeroInt(action.exitCode, 1) } - script.WriteString("exit ") - script.WriteString(strconv.Itoa(finalExitCode)) - if action.onlyIfFile != "" { - script.WriteString(" ; fi") + } + if action.touch != "" { + if err := os.WriteFile(action.touch, nil, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 } - script.WriteString(" ;; esac\n") } - script.WriteString("exit 1\n") - } - if err := os.WriteFile(path, []byte(script.String()), 0o755); err != nil { - t.Fatal(err) + if action.stdout != "" { + fmt.Fprintln(os.Stdout, action.stdout) + } + return fakeCLIFinalExitCode(action) } + return 1 } func fakeCLIFinalExitCode(action fakeCLIAction) int { @@ -1194,12 +1237,6 @@ func firstNonZeroInt(values ...int) int { return 0 } -func escapeBatch(value string) string { - value = strings.ReplaceAll(value, `\`, `\\`) - value = strings.ReplaceAll(value, `"`, `\"`) - return value -} - func TestAWSBrokerCloudFormationTemplateHasBrokerOutput(t *testing.T) { template := awsBrokerCloudFormationTemplate() for _, want := range []string{ @@ -2505,7 +2542,7 @@ func TestNativeConfigCommand(t *testing.T) { func TestGlobalIdentityConfigCommand(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) if err := globalConfigCommand([]string{"--global", "user.name", "Dennis Example"}, ioDiscard{}); err != nil { t.Fatal(err) } diff --git a/setup_test.go b/setup_test.go index 2b8e818..85c3ab2 100644 --- a/setup_test.go +++ b/setup_test.go @@ -398,7 +398,7 @@ func TestSetupDialogCtrlCCancels(t *testing.T) { func TestSetupSSHKeyDiscoveryDedupesKeys(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) sshDir := filepath.Join(home, ".ssh") if err := os.MkdirAll(sshDir, 0o755); err != nil { t.Fatal(err) @@ -425,7 +425,7 @@ func TestSetupSSHKeyDiscoveryDedupesKeys(t *testing.T) { func TestSetupCommandProvisionsGCPAndWritesGlobalConfig(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) pubKey := filepath.Join(home, "owner.pub") if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { t.Fatal(err) @@ -492,7 +492,7 @@ func TestSetupCommandProvisionsGCPAndWritesGlobalConfig(t *testing.T) { func TestSetupCommandOffersGCPProfileCreationWhenNoneExist(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) pubKey := filepath.Join(home, "owner.pub") if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { t.Fatal(err) @@ -859,7 +859,7 @@ func TestBrokerDeleteAWSDeletesStackAndClearsConfig(t *testing.T) { func TestBrokerDeleteGCPDeletesFunctionAndOptionalData(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setTestHome(t, home) configPath := filepath.Join(home, ".bgit", "config") bin := t.TempDir() writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ From f3d66b08d1cd30622de7db0784f1204b040006bc Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 14:33:19 +0200 Subject: [PATCH 06/17] Fix path for bgit binary for develop --- .../workflows/test-and-build-artifacts.yml | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml index 773ee44..4cadbd8 100644 --- a/.github/workflows/test-and-build-artifacts.yml +++ b/.github/workflows/test-and-build-artifacts.yml @@ -219,13 +219,27 @@ jobs: - name: Prepare Built Binary shell: bash run: | - if [[ "${{ matrix.binary }}" == *.exe ]]; then - cp "dist/${{ matrix.binary }}" ./bgit.exe + ls -la dist + binary="${{ matrix.binary }}" + if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then + if [[ "$binary" == *.exe ]]; then + binary="${binary%.exe}-dev.exe" + else + binary="${binary}-dev" + fi + fi + artifact="dist/$binary" + if [[ ! -f "$artifact" ]]; then + echo "Missing expected artifact $binary" >&2 + exit 1 + fi + if [[ "$binary" == *.exe ]]; then + cp "$artifact" ./bgit.exe chmod +x ./bgit.exe printf 'BGIT_PATH=%s/bgit.exe\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" ./bgit.exe --version else - cp "dist/${{ matrix.binary }}" ./bgit + cp "$artifact" ./bgit chmod +x ./bgit printf 'BGIT_PATH=%s/bgit\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" ./bgit --version From 56915a932597334683ae2c48580a26c0c4523cd0 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 14:53:57 +0200 Subject: [PATCH 07/17] harden fixture SSH keys for CI --- .gitattributes | 2 ++ testsuite/aws/ssh_key_types.sh | 2 +- testsuite/gcp/ssh_key_types.sh | 2 +- testsuite/lib/cases/identity_selection.sh | 11 +++++------ testsuite/lib/testlib.sh | 9 +++++++-- testsuite/run-local-broker.sh | 8 +++++++- 6 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6e720e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +testsuite/sshkeys/* text eol=lf + diff --git a/testsuite/aws/ssh_key_types.sh b/testsuite/aws/ssh_key_types.sh index 31a7a37..263cb98 100755 --- a/testsuite/aws/ssh_key_types.sh +++ b/testsuite/aws/ssh_key_types.sh @@ -15,7 +15,7 @@ accept_with_key() { ( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - ssh-add "$key_path" >/dev/null + add_test_key "$key_path" cd "$dir" "$BGIT" admin accept-invite "$code" >/dev/null ) diff --git a/testsuite/gcp/ssh_key_types.sh b/testsuite/gcp/ssh_key_types.sh index d3af392..8750766 100755 --- a/testsuite/gcp/ssh_key_types.sh +++ b/testsuite/gcp/ssh_key_types.sh @@ -15,7 +15,7 @@ accept_with_key() { ( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - ssh-add "$key_path" >/dev/null + add_test_key "$key_path" cd "$dir" "$BGIT" admin accept-invite "$code" >/dev/null ) diff --git a/testsuite/lib/cases/identity_selection.sh b/testsuite/lib/cases/identity_selection.sh index bd22989..9d53ea9 100644 --- a/testsuite/lib/cases/identity_selection.sh +++ b/testsuite/lib/cases/identity_selection.sh @@ -18,8 +18,8 @@ assert_contains "$out" "selected identity: $developer_fp" out="$( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - ssh-add "$(key_path outsider)" >/dev/null - ssh-add "$(key_path developer)" >/dev/null + add_test_key "$(key_path outsider)" + add_test_key "$(key_path developer)" cd "$dir" "$BGIT" --identity "$developer_fp" whoami --refresh )" @@ -39,8 +39,8 @@ assert_contains "$out" '"role": "developer"' out="$( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - ssh-add "$(key_path developer)" >/dev/null - ssh-add "$(key_path read)" >/dev/null + add_test_key "$(key_path developer)" + add_test_key "$(key_path read)" cd "$dir" "$BGIT" whoami --all )" @@ -50,7 +50,7 @@ assert_contains "$out" "warning:" out="$( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - ssh-add "$(key_path developer)" >/dev/null + add_test_key "$(key_path developer)" cd "$dir" "$BGIT" repos mine --json )" @@ -59,4 +59,3 @@ assert_contains "$out" '"role": "developer"' out="$(with_agent_key outsider bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$outsider_fp" 2>&1 || true)" assert_contains "$out" "SSH signature required" - diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh index 09a8914..82ea84d 100755 --- a/testsuite/lib/testlib.sh +++ b/testsuite/lib/testlib.sh @@ -119,14 +119,19 @@ key_fingerprint() { ssh-keygen -lf "$(key_path "$1.pub")" | awk '{print $2}' } +add_test_key() { + local key="$1" + chmod 600 "$key" >/dev/null 2>&1 || true + ssh-add "$key" >/dev/null +} + with_agent_key() { local key="$1" shift ( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - chmod 600 "$(key_path "$key")" >/dev/null 2>&1 || true - ssh-add "$(key_path "$key")" >/dev/null + add_test_key "$(key_path "$key")" "$@" ) } diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh index 3e61426..df86fd5 100755 --- a/testsuite/run-local-broker.sh +++ b/testsuite/run-local-broker.sh @@ -17,6 +17,12 @@ if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then go build -o bgit . fi +for key in "$ROOT"/testsuite/sshkeys/*; do + tmp="${key}.tmp" + tr -d '\r' < "$key" > "$tmp" + mv "$tmp" "$key" +done + run_id="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" tmp_root="${TMPDIR:-${TMP:-/tmp}}" test_root="${BGIT_TEST_LOCAL_BROKER_ROOT:-${tmp_root%/}/bgit-local-broker-${runtime}-${run_id}}" @@ -73,7 +79,7 @@ done curl -sS "${broker_url}/health" >/dev/null eval "$(ssh-agent -s)" >/dev/null -chmod 600 "$ROOT/testsuite/sshkeys/owner" >/dev/null 2>&1 || true +chmod 600 "$ROOT"/testsuite/sshkeys/* >/dev/null 2>&1 || true ssh-add "$ROOT/testsuite/sshkeys/owner" >/dev/null owner_key="$(cat "$ROOT/testsuite/sshkeys/owner.pub")" From 159103e98e696ea02273953012b47f847203a2fd Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 15:06:36 +0200 Subject: [PATCH 08/17] Added native_path() using cygpath -w when available and env vars for Windows integration tests --- testsuite/run-local-broker.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh index df86fd5..3180281 100755 --- a/testsuite/run-local-broker.sh +++ b/testsuite/run-local-broker.sh @@ -17,6 +17,14 @@ if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then go build -o bgit . fi +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s' "$1" + fi +} + for key in "$ROOT"/testsuite/sshkeys/*; do tmp="${key}.tmp" tr -d '\r' < "$key" > "$tmp" @@ -30,6 +38,7 @@ broker_url="http://127.0.0.1:${port}" config_path="${test_root}/home/.bgit/config.yaml" mkdir -p "$(dirname "$config_path")" export HOME="${test_root}/home" +export USERPROFILE="$(native_path "$HOME")" cat > "$config_path" < Date: Tue, 19 May 2026 15:22:48 +0200 Subject: [PATCH 09/17] SSH-agent access into platform-specific files --- go.mod | 1 + go.sum | 2 ++ ssh.go | 13 +------------ ssh_agent_unix.go | 25 +++++++++++++++++++++++++ ssh_agent_windows.go | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 ssh_agent_unix.go create mode 100644 ssh_agent_windows.go diff --git a/go.mod b/go.mod index 6e3ebc7..4277fb4 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( cloud.google.com/go/auth v0.8.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect diff --git a/go.sum b/go.sum index 895c65f..50084f0 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ cloud.google.com/go/longrunning v0.5.11/go.mod h1:rDn7//lmlfWV1Dx6IB4RatCPenTwwm cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= diff --git a/ssh.go b/ssh.go index 5fa4815..16445a6 100644 --- a/ssh.go +++ b/ssh.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "os" "os/exec" @@ -20,7 +19,6 @@ import ( "time" "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" ) const defaultSSHHost = "git.bucketgit.com" @@ -812,16 +810,7 @@ func brokerSignatureHeaderSets(payload []byte) []map[string]string { } func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[string]string { - sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) - if sock == "" { - return nil - } - conn, err := net.Dial("unix", sock) - if err != nil { - return nil - } - defer conn.Close() - signers, err := agent.NewClient(conn).Signers() + signers, err := sshAgentSigners() if err != nil || len(signers) == 0 { return nil } diff --git a/ssh_agent_unix.go b/ssh_agent_unix.go new file mode 100644 index 0000000..50b8ab2 --- /dev/null +++ b/ssh_agent_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "net" + "os" + "strings" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +func sshAgentSigners() ([]ssh.Signer, error) { + sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) + if sock == "" { + return nil, nil + } + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, err + } + defer conn.Close() + return agent.NewClient(conn).Signers() +} diff --git a/ssh_agent_windows.go b/ssh_agent_windows.go new file mode 100644 index 0000000..044792b --- /dev/null +++ b/ssh_agent_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package main + +import ( + "net" + "os" + "strings" + "time" + + "github.com/Microsoft/go-winio" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +const windowsOpenSSHAgentPipe = `\\.\pipe\openssh-ssh-agent` + +func sshAgentSigners() ([]ssh.Signer, error) { + sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) + var conn net.Conn + var err error + if strings.HasPrefix(sock, `\\.\pipe\`) { + conn, err = winio.DialPipe(sock, 5*time.Second) + } else { + conn, err = winio.DialPipe(windowsOpenSSHAgentPipe, 5*time.Second) + } + if err != nil { + return nil, err + } + defer conn.Close() + return agent.NewClient(conn).Signers() +} From 667de71041e5cd9cfaaadbcf1d4b22ebac83f030 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 15:27:20 +0200 Subject: [PATCH 10/17] DialPipe wants a *time.Duration --- ssh_agent_windows.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ssh_agent_windows.go b/ssh_agent_windows.go index 044792b..24fcf06 100644 --- a/ssh_agent_windows.go +++ b/ssh_agent_windows.go @@ -19,10 +19,11 @@ func sshAgentSigners() ([]ssh.Signer, error) { sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) var conn net.Conn var err error + timeout := 5 * time.Second if strings.HasPrefix(sock, `\\.\pipe\`) { - conn, err = winio.DialPipe(sock, 5*time.Second) + conn, err = winio.DialPipe(sock, &timeout) } else { - conn, err = winio.DialPipe(windowsOpenSSHAgentPipe, 5*time.Second) + conn, err = winio.DialPipe(windowsOpenSSHAgentPipe, &timeout) } if err != nil { return nil, err From 07c78c2223696921aa1320c961727127f1192c38 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 15:47:54 +0200 Subject: [PATCH 11/17] Fixed regression, fast tracked CI/CD to just test windows integration --- .../workflows/test-and-build-artifacts.yml | 76 +++++-------------- .github/workflows/test.yml | 13 ++-- ssh.go | 44 ++++++++++- ssh_agent_unix.go | 14 ++-- ssh_agent_windows.go | 12 ++- testsuite/aws/ssh_key_types.sh | 1 + testsuite/gcp/ssh_key_types.sh | 1 + testsuite/lib/cases/identity_selection.sh | 20 +---- testsuite/lib/cases/invites_ownership.sh | 2 +- testsuite/lib/cases/public_private_access.sh | 6 +- testsuite/lib/testlib.sh | 59 +++++++++++++- testsuite/run-local-broker.sh | 1 + 12 files changed, 151 insertions(+), 98 deletions(-) diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml index 4cadbd8..5e95bc4 100644 --- a/.github/workflows/test-and-build-artifacts.yml +++ b/.github/workflows/test-and-build-artifacts.yml @@ -21,45 +21,20 @@ permissions: contents: write jobs: - unit: - name: Unit Tests (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - macos-14 - - macos-15-intel - - ubuntu-24.04 - - windows-2022 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Unit Tests - run: go test ./... - build: - name: Build Multiarchitecture Artifacts - needs: - - unit + name: Build Windows Artifact runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true + cache-dependency-path: go.sum - name: Resolve Version From Changelog if: github.ref == 'refs/heads/main' @@ -142,12 +117,7 @@ jobs: New-Item -ItemType Directory -Force -Path "dist" | Out-Null $targets = @( - @{ GOOS = "darwin"; GOARCH = "arm64"; Artifact = "bgit-mac-arm64" }, - @{ GOOS = "darwin"; GOARCH = "amd64"; Artifact = "bgit-mac-amd64" }, - @{ GOOS = "linux"; GOARCH = "amd64"; Artifact = "bgit-linux-amd64" }, - @{ GOOS = "linux"; GOARCH = "arm64"; Artifact = "bgit-linux-arm64" }, - @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" }, - @{ GOOS = "windows"; GOARCH = "arm64"; Artifact = "bgit-windows-arm64.exe" } + @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" } ) foreach ($target in $targets) { @@ -172,46 +142,36 @@ jobs: } - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bgit-artifacts path: dist/* if-no-files-found: error integration: - name: Integration Tests (${{ matrix.os }}) + name: Windows Integration Tests needs: - build - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-14 - binary: bgit-mac-arm64 - - os: macos-15-intel - binary: bgit-mac-amd64 - - os: ubuntu-24.04 - binary: bgit-linux-amd64 - - os: windows-2022 - binary: bgit-windows-amd64.exe + runs-on: windows-2022 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true + cache-dependency-path: go.sum - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 25 + package-manager-cache: false - name: Download Built Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bgit-artifacts path: dist @@ -220,7 +180,7 @@ jobs: shell: bash run: | ls -la dist - binary="${{ matrix.binary }}" + binary="bgit-windows-amd64.exe" if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then if [[ "$binary" == *.exe ]]; then binary="${binary%.exe}-dev.exe" @@ -262,13 +222,13 @@ jobs: needs: - integration runs-on: ubuntu-24.04 - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + if: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download Built Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bgit-artifacts path: dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 309f1e3..d78c032 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,13 +21,14 @@ jobs: - windows-2022 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true + cache-dependency-path: go.sum - name: Unit Tests run: go test ./... @@ -48,18 +49,20 @@ jobs: - aws steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true + cache-dependency-path: go.sum - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 25 + package-manager-cache: false - name: Local Broker Integration shell: bash diff --git a/ssh.go b/ssh.go index 16445a6..2d10909 100644 --- a/ssh.go +++ b/ssh.go @@ -810,10 +810,17 @@ func brokerSignatureHeaderSets(payload []byte) []map[string]string { } func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[string]string { - signers, err := sshAgentSigners() - if err != nil || len(signers) == 0 { + signers := explicitBrokerSigners() + agentSigners, cleanup, err := sshAgentSigners() + if err == nil && len(agentSigners) > 0 { + signers = append(signers, agentSigners...) + } + if len(signers) == 0 { return nil } + if cleanup != nil { + defer cleanup() + } message := brokerSignatureMessage(payload) preferred := preferredBrokerKeyFingerprints(brokerURL, payload) type signedHeaders struct { @@ -844,6 +851,39 @@ func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[ return sets } +func explicitBrokerSigners() []ssh.Signer { + var paths []string + for _, envName := range []string{"BGIT_SSH_KEY", "BGIT_SSH_KEYS"} { + for _, value := range filepath.SplitList(os.Getenv(envName)) { + if value = strings.TrimSpace(value); value != "" { + paths = append(paths, value) + } + } + } + if value := strings.TrimSpace(brokerIdentityPreference); value != "" && !strings.HasPrefix(value, "SHA256:") { + paths = append(paths, value) + } + seen := map[string]struct{}{} + var signers []ssh.Signer + for _, path := range paths { + path = expandHome(path) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + data, err := os.ReadFile(path) + if err != nil { + continue + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + continue + } + signers = append(signers, signer) + } + return signers +} + func preferredBrokerKeyRank(fingerprint string, preferred []string) int { for i, value := range preferred { if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(fingerprint)) { diff --git a/ssh_agent_unix.go b/ssh_agent_unix.go index 50b8ab2..b7a0436 100644 --- a/ssh_agent_unix.go +++ b/ssh_agent_unix.go @@ -11,15 +11,19 @@ import ( "golang.org/x/crypto/ssh/agent" ) -func sshAgentSigners() ([]ssh.Signer, error) { +func sshAgentSigners() ([]ssh.Signer, func(), error) { sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) if sock == "" { - return nil, nil + return nil, nil, nil } conn, err := net.Dial("unix", sock) if err != nil { - return nil, err + return nil, nil, err } - defer conn.Close() - return agent.NewClient(conn).Signers() + signers, err := agent.NewClient(conn).Signers() + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return signers, func() { _ = conn.Close() }, nil } diff --git a/ssh_agent_windows.go b/ssh_agent_windows.go index 24fcf06..2a9e064 100644 --- a/ssh_agent_windows.go +++ b/ssh_agent_windows.go @@ -15,7 +15,7 @@ import ( const windowsOpenSSHAgentPipe = `\\.\pipe\openssh-ssh-agent` -func sshAgentSigners() ([]ssh.Signer, error) { +func sshAgentSigners() ([]ssh.Signer, func(), error) { sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) var conn net.Conn var err error @@ -26,8 +26,12 @@ func sshAgentSigners() ([]ssh.Signer, error) { conn, err = winio.DialPipe(windowsOpenSSHAgentPipe, &timeout) } if err != nil { - return nil, err + return nil, nil, err } - defer conn.Close() - return agent.NewClient(conn).Signers() + signers, err := agent.NewClient(conn).Signers() + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return signers, func() { _ = conn.Close() }, nil } diff --git a/testsuite/aws/ssh_key_types.sh b/testsuite/aws/ssh_key_types.sh index 263cb98..2ed6abf 100755 --- a/testsuite/aws/ssh_key_types.sh +++ b/testsuite/aws/ssh_key_types.sh @@ -16,6 +16,7 @@ accept_with_key() { eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT add_test_key "$key_path" + export BGIT_SSH_KEY="$(native_path "$key_path")" cd "$dir" "$BGIT" admin accept-invite "$code" >/dev/null ) diff --git a/testsuite/gcp/ssh_key_types.sh b/testsuite/gcp/ssh_key_types.sh index 8750766..9bfcc69 100755 --- a/testsuite/gcp/ssh_key_types.sh +++ b/testsuite/gcp/ssh_key_types.sh @@ -16,6 +16,7 @@ accept_with_key() { eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT add_test_key "$key_path" + export BGIT_SSH_KEY="$(native_path "$key_path")" cd "$dir" "$BGIT" admin accept-invite "$code" >/dev/null ) diff --git a/testsuite/lib/cases/identity_selection.sh b/testsuite/lib/cases/identity_selection.sh index 9d53ea9..bd28a80 100644 --- a/testsuite/lib/cases/identity_selection.sh +++ b/testsuite/lib/cases/identity_selection.sh @@ -16,12 +16,7 @@ assert_contains "$out" "role: developer" assert_contains "$out" "selected identity: $developer_fp" out="$( - eval "$(ssh-agent -s)" >/dev/null - trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - add_test_key "$(key_path outsider)" - add_test_key "$(key_path developer)" - cd "$dir" - "$BGIT" --identity "$developer_fp" whoami --refresh + with_agent_keys outsider,developer bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$developer_fp" )" assert_contains "$out" "role: developer" @@ -37,22 +32,13 @@ out="$(with_agent_key developer bash -c 'cd "$1" && "$2" whoami --json' _ "$dir" assert_contains "$out" '"role": "developer"' out="$( - eval "$(ssh-agent -s)" >/dev/null - trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - add_test_key "$(key_path developer)" - add_test_key "$(key_path read)" - cd "$dir" - "$BGIT" whoami --all + with_agent_keys developer,read bash -c 'cd "$1" && "$2" whoami --all' _ "$dir" "$BGIT" )" assert_contains "$out" "developer" assert_contains "$out" "reader" assert_contains "$out" "warning:" out="$( - eval "$(ssh-agent -s)" >/dev/null - trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - add_test_key "$(key_path developer)" - cd "$dir" - "$BGIT" repos mine --json + with_agent_key developer bash -c 'cd "$1" && "$2" repos mine --json' _ "$dir" "$BGIT" )" assert_contains "$out" '"repos"' assert_contains "$out" '"role": "developer"' diff --git a/testsuite/lib/cases/invites_ownership.sh b/testsuite/lib/cases/invites_ownership.sh index b594ff1..fd02e05 100644 --- a/testsuite/lib/cases/invites_ownership.sh +++ b/testsuite/lib/cases/invites_ownership.sh @@ -28,7 +28,7 @@ out="$(run_in "$dir" admin cancel-invite --user code-cancelled)" assert_contains "$out" "cancelled invite for code-cancelled" out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" assert_contains "$out" "invite is not pending or has expired" -out="$(SSH_AUTH_SOCK= expect_failure "$BGIT" admin accept-invite "$invite_code")" +out="$(without_ssh_identity expect_failure "$BGIT" admin accept-invite "$invite_code")" assert_contains "$out" "SSH signature required" out="$(with_agent_key outsider "$BGIT" admin accept-invite "$invite_code")" assert_contains "$out" "accepted invite for invited-dev as developer" diff --git a/testsuite/lib/cases/public_private_access.sh b/testsuite/lib/cases/public_private_access.sh index aa6b5ff..0bec654 100644 --- a/testsuite/lib/cases/public_private_access.sh +++ b/testsuite/lib/cases/public_private_access.sh @@ -13,7 +13,7 @@ run_in "$dir" push -u origin main >/dev/null private_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-private-$RUN_ID" private_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-private-$RUN_ID" -out="$(SSH_AUTH_SOCK= expect_failure "$BGIT" clone "$clone_url" "$private_no_key")" +out="$(without_ssh_identity expect_failure "$BGIT" clone "$clone_url" "$private_no_key")" assert_contains "$out" "broker denied read access" out="$(with_agent_key outsider expect_failure "$BGIT" clone "$clone_url" "$private_unknown")" assert_contains "$out" "broker denied read access" @@ -22,7 +22,7 @@ run_in "$dir" admin repo visibility public >/dev/null public_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-public-$RUN_ID" public_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-public-$RUN_ID" -SSH_AUTH_SOCK= expect_success "$BGIT" clone "$clone_url" "$public_no_key" >/dev/null +without_ssh_identity expect_success "$BGIT" clone "$clone_url" "$public_no_key" >/dev/null assert_file_exists "$public_no_key/README.md" assert_contains "$(cat "$public_no_key/README.md")" "public private access" with_agent_key outsider expect_success "$BGIT" clone "$clone_url" "$public_unknown" >/dev/null @@ -30,5 +30,5 @@ assert_file_exists "$public_unknown/README.md" assert_contains "$(cat "$public_unknown/README.md")" "public private access" run_in "$dir" admin repo visibility private >/dev/null -out="$(cd "$public_no_key" && SSH_AUTH_SOCK= expect_failure "$BGIT" ls-remote)" +out="$(cd "$public_no_key" && without_ssh_identity expect_failure "$BGIT" ls-remote)" assert_contains "$out" "read SSH signature required" diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh index 82ea84d..e8e1ecd 100755 --- a/testsuite/lib/testlib.sh +++ b/testsuite/lib/testlib.sh @@ -119,6 +119,22 @@ key_fingerprint() { ssh-keygen -lf "$(key_path "$1.pub")" | awk '{print $2}' } +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s' "$1" + fi +} + +path_list_separator() { + if command -v cygpath >/dev/null 2>&1; then + printf ';' + else + printf ':' + fi +} + add_test_key() { local key="$1" chmod 600 "$key" >/dev/null 2>&1 || true @@ -131,7 +147,44 @@ with_agent_key() { ( eval "$(ssh-agent -s)" >/dev/null trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT - add_test_key "$(key_path "$key")" + local path + path="$(key_path "$key")" + add_test_key "$path" + export BGIT_SSH_KEY="$(native_path "$path")" + unset BGIT_SSH_KEYS + "$@" + ) +} + +with_agent_keys() { + local keys_csv="$1" + shift + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + unset BGIT_SSH_KEY + local sep paths key path + sep="$(path_list_separator)" + paths="" + IFS=',' read -r -a keys <<< "$keys_csv" + for key in "${keys[@]}"; do + path="$(key_path "$key")" + add_test_key "$path" + if [[ -n "$paths" ]]; then + paths="${paths}${sep}" + fi + paths="${paths}$(native_path "$path")" + done + export BGIT_SSH_KEYS="$paths" + "$@" + ) +} + +without_ssh_identity() { + ( + unset SSH_AUTH_SOCK + unset BGIT_SSH_KEY + unset BGIT_SSH_KEYS "$@" ) } @@ -146,13 +199,13 @@ run_in_as() { run_in_no_agent() { local dir="$1" shift - (cd "$dir" && SSH_AUTH_SOCK= "$BGIT" "$@") + (cd "$dir" && without_ssh_identity "$BGIT" "$@") } expect_failure_no_agent() { local dir="$1" shift - (cd "$dir" && SSH_AUTH_SOCK= expect_failure "$BGIT" "$@") + (cd "$dir" && without_ssh_identity expect_failure "$BGIT" "$@") } expect_failure_in_as() { diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh index 3180281..dc9746a 100755 --- a/testsuite/run-local-broker.sh +++ b/testsuite/run-local-broker.sh @@ -90,6 +90,7 @@ curl -sS "${broker_url}/health" >/dev/null eval "$(ssh-agent -s)" >/dev/null chmod 600 "$ROOT"/testsuite/sshkeys/* >/dev/null 2>&1 || true ssh-add "$ROOT/testsuite/sshkeys/owner" >/dev/null +export BGIT_SSH_KEY="$(native_path "$ROOT/testsuite/sshkeys/owner")" owner_key="$(cat "$ROOT/testsuite/sshkeys/owner.pub")" curl -sS -X POST "${broker_url}/owners/upsert" \ From 1c5352b4dd447abae4dc5183a7503f4c8119cd23 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 15:52:43 +0200 Subject: [PATCH 12/17] Fixed Windows native Git transport issue --- broker_commands.go | 20 ++++++++++++++++---- broker_commands_test.go | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/broker_commands.go b/broker_commands.go index 4a4fdb0..e0e6261 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -2006,10 +2006,7 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity repoName += ".git" } remoteURL := fmt.Sprintf("git@%s:%s", defaultSSHHost, repoName) - sshCommand := "bgit ssh" - if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" { - sshCommand = exe + " ssh" - } + sshCommand := gitSSHCommandForExecutable() pairs := [][]string{ {"bucketgit.broker", profile.BrokerURL}, {"bucketgit.profile", profile.QualifiedName}, @@ -2043,6 +2040,21 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity return nil } +func gitSSHCommandForExecutable() string { + exe, err := os.Executable() + if err != nil || strings.TrimSpace(exe) == "" { + return "bgit ssh" + } + return shellQuoteForGitSSHCommand(exe) + " ssh" +} + +func shellQuoteForGitSSHCommand(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + func writeLocalIdentityConfig(target, name, email string) error { absTarget, err := filepath.Abs(target) if err != nil { diff --git a/broker_commands_test.go b/broker_commands_test.go index 06b9f3d..5e60a36 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -58,6 +58,9 @@ func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { if got := strings.TrimSpace(string(out)); !strings.HasSuffix(got, " ssh") { t.Fatalf("core.sshCommand = %q", got) } + if got := strings.TrimSpace(string(out)); !strings.HasPrefix(got, "'") { + t.Fatalf("core.sshCommand should quote executable path, got %q", got) + } remote, err := runGit(target, "remote", "get-url", "origin") if err != nil { t.Fatal(err) @@ -67,6 +70,21 @@ func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { } } +func TestShellQuoteForGitSSHCommand(t *testing.T) { + got := shellQuoteForGitSSHCommand(`D:\a\bgit\bgit\bgit.exe`) + if got != `'D:\a\bgit\bgit\bgit.exe'` { + t.Fatalf("quoted windows path = %q", got) + } + got = shellQuoteForGitSSHCommand(`/tmp/BucketGit Test/bin/bgit`) + if got != `'/tmp/BucketGit Test/bin/bgit'` { + t.Fatalf("quoted unix path = %q", got) + } + got = shellQuoteForGitSSHCommand(`/tmp/dennis' test/bgit`) + if got != `'/tmp/dennis'\'' test/bgit'` { + t.Fatalf("quoted apostrophe path = %q", got) + } +} + func TestInitBrokerWorktreeOmitsIdentityWhenUnset(t *testing.T) { target := filepath.Join(t.TempDir(), "app") err := initBrokerWorktree(target, "team/app", brokerProfile{ From d3ce29ab45db379c8194c89dad3322c8e2132971 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 15:59:27 +0200 Subject: [PATCH 13/17] Fix Windows CRLF test line endings --- testsuite/aws/push_fetch_pull_lsremote.sh | 1 + testsuite/gcp/push_fetch_pull_lsremote.sh | 1 + testsuite/lib/testlib.sh | 2 ++ 3 files changed, 4 insertions(+) diff --git a/testsuite/aws/push_fetch_pull_lsremote.sh b/testsuite/aws/push_fetch_pull_lsremote.sh index cbaa10c..ef1de95 100755 --- a/testsuite/aws/push_fetch_pull_lsremote.sh +++ b/testsuite/aws/push_fetch_pull_lsremote.sh @@ -10,6 +10,7 @@ assert_contains "$out" "refs/heads/main" clone="$SUITE_ROOT/aws/repo/remote-clone-$RUN_ID" rm -rf "$clone" expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +init_local_git_identity "$clone" assert_file_exists "$clone/README.md" printf 'gcp pull\n' >> "$dir/README.md" run_in "$dir" add README.md >/dev/null diff --git a/testsuite/gcp/push_fetch_pull_lsremote.sh b/testsuite/gcp/push_fetch_pull_lsremote.sh index ee0e04b..d63cce0 100755 --- a/testsuite/gcp/push_fetch_pull_lsremote.sh +++ b/testsuite/gcp/push_fetch_pull_lsremote.sh @@ -10,6 +10,7 @@ assert_contains "$out" "refs/heads/main" clone="$SUITE_ROOT/gcp/repo/remote-clone-$RUN_ID" rm -rf "$clone" expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +init_local_git_identity "$clone" assert_file_exists "$clone/README.md" printf 'gcp pull\n' >> "$dir/README.md" run_in "$dir" add README.md >/dev/null diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh index e8e1ecd..bb366f2 100755 --- a/testsuite/lib/testlib.sh +++ b/testsuite/lib/testlib.sh @@ -82,6 +82,8 @@ new_workdir() { init_local_git_identity() { git -C "$1" config user.name "BucketGit Tests" git -C "$1" config user.email "tests@bucketgit.local" + git -C "$1" config core.autocrlf false + git -C "$1" config core.eol lf } init_bgit_repo() { From 415347f7618388b3815aab0def064d52a58766c8 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 16:13:24 +0200 Subject: [PATCH 14/17] Fix Windows native transport line endings II --- broker_commands.go | 3 +++ broker_commands_test.go | 2 ++ main.go | 19 +++++++++++++++++++ testsuite/lib/cases/native_git_transport.sh | 5 ++++- testsuite/lib/testlib.sh | 2 -- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/broker_commands.go b/broker_commands.go index e0e6261..417ccea 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -2026,6 +2026,9 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity return err } } + if err := configureBucketGitLineEndings(absTarget); err != nil { + return err + } if err := setGitOrigin(absTarget, remoteURL); err != nil { return err } diff --git a/broker_commands_test.go b/broker_commands_test.go index 5e60a36..9d9a76d 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -42,6 +42,8 @@ func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { "bucketgit.logicalRepo": "team/app.git", "branch.main.remote": "origin", "branch.main.merge": "refs/heads/main", + "core.autocrlf": "false", + "core.eol": "lf", } { out, err := runGit(target, "config", "--get", key) if err != nil { diff --git a/main.go b/main.go index 3dd89dc..a971d42 100644 --- a/main.go +++ b/main.go @@ -1476,6 +1476,9 @@ func initEmptyWorktree(args []string, stdout io.Writer) error { if _, err := runGit(absTarget, "config", "--local", "bucketgit.branch", *branch); err != nil { return err } + if err := configureBucketGitLineEndings(absTarget); err != nil { + return err + } fmt.Fprintf(stdout, "Initialized empty Git repository in %s/\n", filepath.Join(absTarget, ".git")) return nil } @@ -1502,6 +1505,22 @@ func writeBucketGitConfig(worktree string, cfg config) error { return err } } + if err := configureBucketGitLineEndings(worktree); err != nil { + return err + } + return nil +} + +func configureBucketGitLineEndings(worktree string) error { + pairs := [][]string{ + {"core.autocrlf", "false"}, + {"core.eol", "lf"}, + } + for _, pair := range pairs { + if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } return nil } diff --git a/testsuite/lib/cases/native_git_transport.sh b/testsuite/lib/cases/native_git_transport.sh index bead359..e71ab30 100644 --- a/testsuite/lib/cases/native_git_transport.sh +++ b/testsuite/lib/cases/native_git_transport.sh @@ -40,6 +40,9 @@ printf 'remote update\n' >> "$dir/README.md" run_in "$dir" add README.md >/dev/null run_in "$dir" commit -m "remote update" >/dev/null run_in "$dir" push >/dev/null +if [[ -n "$(git -C "$clone" status --porcelain)" ]]; then + git -C "$clone" status --porcelain >&2 + fail "native clone should be clean before pull" +fi (cd "$clone" && git checkout main >/dev/null && git pull >/dev/null) assert_contains "$(cat "$clone/README.md")" "remote update" - diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh index bb366f2..e8e1ecd 100755 --- a/testsuite/lib/testlib.sh +++ b/testsuite/lib/testlib.sh @@ -82,8 +82,6 @@ new_workdir() { init_local_git_identity() { git -C "$1" config user.name "BucketGit Tests" git -C "$1" config user.email "tests@bucketgit.local" - git -C "$1" config core.autocrlf false - git -C "$1" config core.eol lf } init_bgit_repo() { From d6c1ff45759e8645df54ec8c37f3d815276dd2b9 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 16:20:42 +0200 Subject: [PATCH 15/17] =?UTF-8?q?Go=E2=80=99s=20ECDSA=20signer=20needs=20a?= =?UTF-8?q?=20randomness=20source=20and=20we=20passed=20nil.=20Signature?= =?UTF-8?q?=20calls=20now=20pass=20crypto/rand.Reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ssh.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ssh.go b/ssh.go index 2d10909..e2b78f6 100644 --- a/ssh.go +++ b/ssh.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" @@ -829,7 +830,7 @@ func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[ } var signed []signedHeaders for _, signer := range signers { - sig, err := signer.Sign(nil, message) + sig, err := signer.Sign(rand.Reader, message) if err != nil { continue } From 2ce1011ef32b737f2c623cc0f31e77515209ce57 Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 16:29:49 +0200 Subject: [PATCH 16/17] Re-enable full CI/CD --- .../workflows/test-and-build-artifacts.yml | 74 +++++++++++++++++-- .github/workflows/test.yml | 10 +++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml index 5e95bc4..219f7d4 100644 --- a/.github/workflows/test-and-build-artifacts.yml +++ b/.github/workflows/test-and-build-artifacts.yml @@ -18,12 +18,49 @@ on: workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: test-and-build-${{ github.ref }} + cancel-in-progress: true jobs: + unit: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Unit Tests + run: go test ./... + build: - name: Build Windows Artifact + name: Build Multiarchitecture Artifacts + needs: + - unit runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read steps: - name: Checkout @@ -117,7 +154,12 @@ jobs: New-Item -ItemType Directory -Force -Path "dist" | Out-Null $targets = @( - @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" } + @{ GOOS = "darwin"; GOARCH = "arm64"; Artifact = "bgit-mac-arm64" }, + @{ GOOS = "darwin"; GOARCH = "amd64"; Artifact = "bgit-mac-amd64" }, + @{ GOOS = "linux"; GOARCH = "amd64"; Artifact = "bgit-linux-amd64" }, + @{ GOOS = "linux"; GOARCH = "arm64"; Artifact = "bgit-linux-arm64" }, + @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" }, + @{ GOOS = "windows"; GOARCH = "arm64"; Artifact = "bgit-windows-arm64.exe" } ) foreach ($target in $targets) { @@ -149,10 +191,25 @@ jobs: if-no-files-found: error integration: - name: Windows Integration Tests + name: Integration Tests (${{ matrix.os }}) needs: - build - runs-on: windows-2022 + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + binary: bgit-mac-arm64 + - os: macos-15-intel + binary: bgit-mac-amd64 + - os: ubuntu-24.04 + binary: bgit-linux-amd64 + - os: windows-2022 + binary: bgit-windows-amd64.exe steps: - name: Checkout uses: actions/checkout@v6 @@ -180,7 +237,7 @@ jobs: shell: bash run: | ls -la dist - binary="bgit-windows-amd64.exe" + binary="${{ matrix.binary }}" if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then if [[ "$binary" == *.exe ]]; then binary="${binary%.exe}-dev.exe" @@ -222,7 +279,10 @@ jobs: needs: - integration runs-on: ubuntu-24.04 - if: false + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + timeout-minutes: 15 + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d78c032..ccbeac3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,10 +7,17 @@ on: permissions: contents: read +concurrency: + group: test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: unit: name: Unit Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -36,6 +43,9 @@ jobs: local-broker-integration: name: Local Broker Integration (${{ matrix.runtime }} / ${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read strategy: fail-fast: false matrix: From b5d4d5b8a4538accedccbe543c0c29fd75ec495f Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Tue, 19 May 2026 16:45:44 +0200 Subject: [PATCH 17/17] Do best effort clean up of artifacts at the very start, and not make it fail the entire run --- testsuite/run-local-broker.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh index dc9746a..86098d8 100755 --- a/testsuite/run-local-broker.sh +++ b/testsuite/run-local-broker.sh @@ -25,6 +25,13 @@ native_path() { fi } +remove_test_artifacts_best_effort() { + local path="$1" + if [[ -e "$path" ]]; then + rm -rf "$path" >/dev/null 2>&1 || true + fi +} + for key in "$ROOT"/testsuite/sshkeys/*; do tmp="${key}.tmp" tr -d '\r' < "$key" > "$tmp" @@ -36,6 +43,9 @@ tmp_root="${TMPDIR:-${TMP:-/tmp}}" test_root="${BGIT_TEST_LOCAL_BROKER_ROOT:-${tmp_root%/}/bgit-local-broker-${runtime}-${run_id}}" broker_url="http://127.0.0.1:${port}" config_path="${test_root}/home/.bgit/config.yaml" +remove_test_artifacts_best_effort "$test_root" +remove_test_artifacts_best_effort "$ROOT/testsuite/local/repo" +remove_test_artifacts_best_effort "$ROOT/testsuite/${provider}/repo" mkdir -p "$(dirname "$config_path")" export HOME="${test_root}/home" export USERPROFILE="$(native_path "$HOME")" @@ -67,14 +77,9 @@ status=0 cleanup() { status=$? kill "$broker_pid" >/dev/null 2>&1 || true + wait "$broker_pid" >/dev/null 2>&1 || true if [[ -n "${SSH_AGENT_PID:-}" ]]; then ssh-agent -k >/dev/null 2>&1 || true; fi - if [[ "$status" -eq 0 && "${BGIT_TEST_KEEP_ARTIFACTS:-}" != "1" ]]; then - rm -rf "$test_root" - rm -rf "$ROOT/testsuite/local/repo" - rm -rf "$ROOT/testsuite/${provider}/repo" - else - printf 'kept test artifacts in %s and %s\n' "$test_root" "$ROOT/testsuite/${provider}/repo" >&2 - fi + printf 'kept test artifacts in %s and %s\n' "$test_root" "$ROOT/testsuite/${provider}/repo" >&2 exit "$status" } trap cleanup EXIT