Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/extensions/replica-prototype/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ Without a usable connection, the canvas offers a dismissible dialog once per ope

Seven-day access tokens renew automatically while repository access remains valid and the connection has not been revoked or inactive for 30 days. Focus and user-initiated Hub actions count as activity; background polling and renewal do not. Expiry or revocation requires approval again. Temporary outages keep the preview usable and preserve saved credentials. Older connections keep their original expiry and need one new approval to become renewable. **Disconnect Hub** revokes the selected connection; you can also revoke it at `/canvas/connections` in Hub. Switching Hubs does not revoke an earlier connection.

The canvas follows branch changes. If Hub has not discovered the branch, the repository remains connected; push the branch and wait for discovery to use its Builds and reviews. Once Hub imports a successful Build of the current checkout, the canvas automatically registers eligible prototypes using the branch name as the initial title. Existing metadata is preserved. Use **Edit metadata** in **Hub** to change the title or description afterward. Registration requires write access and Hub's branch eligibility checks; errors appear in the shared critical banner and status polling retries automatically. Updated Hub APIs supply a `registrationReason` code explaining missing write access, excluded branches, missing remote heads, or branch authorship requirements. Unknown codes and older Hubs retain a generic error; independently verified permission and remote-ref facts can supply a specific explanation. Installing the extension does not deploy the updated Hub API.
The canvas follows branch changes. If Hub has not discovered the branch, the repository remains connected and comments can be saved locally; push the branch and wait for discovery to use its published Builds and reviews. Once Hub imports a successful Build of the current checkout, the canvas automatically registers eligible prototypes using the branch name as the initial title. Existing metadata is preserved. Use **Edit metadata** in **Hub** to change the title or description afterward. Registration requires write access and Hub's branch eligibility checks; errors appear in the shared critical banner and status polling retries automatically. Updated Hub APIs supply a `registrationReason` code explaining missing write access, excluded branches, missing remote heads, or branch authorship requirements. Unknown codes and older Hubs retain a generic error; independently verified permission and remote-ref facts can supply a specific explanation. Installing the extension does not deploy the updated Hub API.

For a Hub started in [local development mode](https://github.com/github/replica/blob/main/hub/docs/local-development.md), open Copilot in Hub's `.local/prototypes/example` checkout and pass `"repository": "local/example"` when opening the canvas. Local repository hints only apply to matching checkouts under `.local/prototypes/` with a loopback Hub origin; they cannot redirect a remote checkout to another Hub repository. Start that example's dev server on a port reachable from the canvas before opening it.

Expand All @@ -282,7 +282,9 @@ Pending commit-and-publish requests remain deduplicated across verified descenda

Copilot uses the existing push workflow, not manual Actions dispatch or another publishing path. It follows `refresh_status`, registers discovered branches with `update_metadata`, and preserves saved metadata. If it stops before pushing, it calls `report_publish_result` with the request target and a `blocked`, `canceled`, or `failed` outcome so the canvas stops waiting and offers a retry. A later push of the current HEAD clears that failure so the canvas follows the new workflow and Build. The action rejects other canvases, targets, or modes, and any request whose push was already observed. It waits for Hub's imported Build matching the pushed commit and returns its exact URL. An older Build or Actions success alone is not enough. The local preview stays open. Entry-page checks still apply; incomplete landing-page verification is reported, not silently repaired and republished.

The canvas header uses Hub's review controls: **Add a comment** sits beside a comment count with unread state, and selecting the count opens the dedicated comments sidebar. The sidebar includes Hub's search, build/page/participant/read/resolved filters, pin visibility control, conversation rows, and thread view; the **Hub** panel remains focused on connection, prototype metadata, and publication state. Select a preview marker or conversation row to open the thread beside its target and reply without leaving the canvas. Markers use the same participant avatars, overflow, unread, resolved, focus, and accessible-label behavior as Hub. When the local checkout matches the imported Build's exact commit, **Add a comment** supports the same element and highlighted-area targets as Hub and saves the new thread directly to Hub. You can also reply, edit or delete your own messages, react, and resolve or reopen threads. Live changes arrive over Hub's review channel, with periodic refresh if that connection is unavailable. Hub credentials stay in the extension process and are never sent to the local preview.
**Add a comment** targets an element or highlighted area. Comments on unpublished changes stay on the author's device until a matching Build is published; comments on a clean checkout of the published Build save directly to Hub. See [local preview comments](../../../hub/docs/prototype-canvas.md#local-preview-comments) for requirements, storage and recovery.

The header's comment count opens a sidebar with Hub's search, filters, unread state and pin visibility controls. Select a marker or row to open its conversation beside the target. Published conversations support replies, edits, deletion, reactions and resolution. Hub sends live updates, with periodic refresh when unavailable. The **Hub** panel remains focused on connection, metadata and publication. Hub credentials never reach the local preview.

Open an already-running unauthenticated loopback prototype in this canvas. The extension's per-panel reverse proxy supplies its bundled bridge to ordinary Vite dev/preview and static HTML servers. Multiple prototypes, including servers in the same checkout, have independent proxy ports and lifecycles. Closing a panel stops only its proxy.

Expand Down

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions .github/extensions/replica-prototype/assets/renderer.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { mkdtemp, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"

import { runCommand } from "../run-command/index.mjs"

export async function captureReviewRevision(context) {
const directory = await mkdtemp(path.join(os.tmpdir(), "replica-review-"))
const env = { ...process.env, GIT_INDEX_FILE: path.join(directory, "index") }
const config = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"]
async function git(args, optional = false) {
const result = await runCommand("git", [...config, ...args], context.repositoryPath, { env })
if (!result.ok && !(optional && result.exitCode === 1))
throw new Error(`Could not capture the local review revision: ${result.error}`)
return result
}
try {
// Snapshotting must not execute repository-defined clean filters or filter processes.
const filters = await git(
["config", "--name-only", "--get-regexp", "^filter\\..*\\.(clean|process|required)$"],
true,
)
for (const key of filters.stdout.trim().split("\n").filter(Boolean)) {
config.push("-c", `${key}=${key.endsWith(".required") ? "false" : ""}`)
}
const head = (await git(["rev-parse", "HEAD"])).stdout.trim()
const branch = (await git(["branch", "--show-current"])).stdout.trim()
const headTree = (await git(["rev-parse", "HEAD^{tree}"])).stdout.trim()
if (!/^[0-9a-f]{40}$/.test(head) || !branch)
throw new Error("Open a committed, named branch before commenting.")
await git(["read-tree", head])
const files = (
await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"])
).stdout
.split("\0")
.filter(Boolean)
for (let offset = 0; offset < files.length; offset += 100) {
const attributes = (
await git([
"--literal-pathspecs",
"check-attr",
"-z",
"filter",
"--",
...files.slice(offset, offset + 100),
])
).stdout.split("\0")
for (let index = 2; index < attributes.length; index += 3) {
if (!["unspecified", "unset"].includes(attributes[index]))
throw new Error("Local review revisions do not support Git clean filters.")
}
}
const submodules = await git([
"diff",
"--no-ext-diff",
"--no-textconv",
"--ignore-submodules=none",
"--name-only",
"-z",
"--diff-filter=M",
head,
"--",
])
const changedPaths = new Set(submodules.stdout.split("\0"))
const entries = (await git(["ls-files", "--stage", "-z"])).stdout
if (
entries
.split("\0")
.some(
(entry) =>
entry.startsWith("160000 ") && changedPaths.has(entry.slice(entry.indexOf("\t") + 1)),
)
)
throw new Error("Commit or discard submodule changes before commenting on this preview.")
await git(["add", "--all", "--", "."])
const treeSha = (await git(["write-tree"])).stdout.trim()
const currentHead = (await git(["rev-parse", "HEAD"])).stdout.trim()
const currentBranch = (await git(["branch", "--show-current"])).stdout.trim()
const remaining = await git(
["diff", "--no-ext-diff", "--no-textconv", "--quiet", "--ignore-submodules=none"],
true,
)
const untracked = (await git(["ls-files", "--others", "--exclude-standard"])).stdout
if (currentHead !== head || currentBranch !== branch || !remaining.ok || untracked)
throw new Error("The checkout changed while capturing this review revision. Try again.")
if (!/^[0-9a-f]{40}$/.test(treeSha))
throw new Error("Could not verify the local review source tree.")
return { treeSha, commitSha: head, clean: treeSha === headTree }
} finally {
await rm(directory, { recursive: true, force: true })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { describe, it } from "node:test"

import { captureReviewRevision } from "./index.mjs"

async function withRepository(run) {
const workspace = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
const repositoryPath = path.join(workspace, "repository")
await mkdir(repositoryPath, { recursive: true })
function git(...args) {
return execFileSync("git", args, {
cwd: repositoryPath,
encoding: "utf8",
stdio: "pipe",
}).trim()
}
try {
git("init", "-q", "-b", "prototype")
git("config", "user.name", "Replica")
git("config", "user.email", "replica@example.com")
git("config", "commit.gpgsign", "false")
await writeFile(path.join(repositoryPath, "tracked.txt"), "original\n")
await writeFile(path.join(repositoryPath, ".gitignore"), "ignored.txt\n")
git("add", ".")
git("commit", "-qm", "Fixture")
await run({ repositoryPath }, git, workspace)
} finally {
await rm(workspace, { recursive: true, force: true })
}
}

describe("captureReviewRevision()", () => {
it("returns the actual HEAD tree and commit for a clean checkout and excludes ignored files", async () => {
await withRepository(async (context, git) => {
await writeFile(path.join(context.repositoryPath, "ignored.txt"), "local secret\n")

const revision = await captureReviewRevision(context)

assert.deepEqual(revision, {
treeSha: git("rev-parse", "HEAD^{tree}"),
commitSha: git("rev-parse", "HEAD"),
clean: true,
})
await writeFile(path.join(context.repositoryPath, "ignored.txt"), "changed secret\n")
assert.deepEqual(await captureReviewRevision(context), revision)
})
})

it("captures working-tree contents rather than staged contents without changing the real index", async () => {
await withRepository(async (context, git) => {
const tracked = path.join(context.repositoryPath, "tracked.txt")
await writeFile(tracked, "staged\n")
git("add", "tracked.txt")
const indexPath = path.join(context.repositoryPath, ".git/index")
const stagedTree = git("write-tree")
const index = await readFile(indexPath)
const stagedRevision = await captureReviewRevision(context)
assert.equal(stagedRevision.treeSha, stagedTree)
assert.equal(stagedRevision.clean, false)
assert.deepEqual(await readFile(indexPath), index)
await writeFile(tracked, "unstaged after staging\n")

const revision = await captureReviewRevision(context)

assert.equal(revision.clean, false)
assert.equal(revision.commitSha, git("rev-parse", "HEAD"))
assert.notEqual(revision.treeSha, stagedTree)
assert.notEqual(revision.treeSha, git("rev-parse", "HEAD^{tree}"))
assert.equal(git("show", `${revision.treeSha}:tracked.txt`), "unstaged after staging")
assert.deepEqual(await readFile(indexPath), index)
assert.equal(git("write-tree"), stagedTree)
assert.deepEqual(await captureReviewRevision(context), revision)
})
})

it("includes nonignored untracked files even when Git status hides them", async () => {
await withRepository(async (context, git) => {
git("config", "status.showUntrackedFiles", "no")
const originalTree = git("rev-parse", "HEAD^{tree}")
await writeFile(path.join(context.repositoryPath, "new file.txt"), "new contents\n")

const revision = await captureReviewRevision(context)

assert.equal(revision.clean, false)
assert.notEqual(revision.treeSha, originalTree)
assert.equal(git("show", `${revision.treeSha}:new file.txt`), "new contents")
assert.equal(git("diff", "--cached"), "")
await rm(path.join(context.repositoryPath, "new file.txt"))
assert.equal((await captureReviewRevision(context)).treeSha, originalTree)
})
})

it("records tracked deletions without staging them", async () => {
await withRepository(async (context, git) => {
await rm(path.join(context.repositoryPath, "tracked.txt"))

const revision = await captureReviewRevision(context)

assert.equal(revision.clean, false)
assert.notEqual(revision.treeSha, git("rev-parse", "HEAD^{tree}"))
assert.equal(git("ls-tree", "--name-only", revision.treeSha, "tracked.txt"), "")
assert.equal(git("diff", "--cached"), "")
})
})

it("rejects dirty submodules instead of claiming their unchanged gitlink identifies the preview", async () => {
await withRepository(async (context, git, workspace) => {
const source = path.join(workspace, "submodule-source")
await mkdir(source)
execFileSync("git", ["init", "-q", source], { stdio: "pipe" })
await writeFile(path.join(source, "component.txt"), "original\n")
execFileSync("git", ["-C", source, "add", "."], { stdio: "pipe" })
execFileSync(
"git",
[
"-C",
source,
"-c",
"user.name=Replica",
"-c",
"user.email=replica@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"Submodule fixture",
],
{ stdio: "pipe" },
)
git("-c", "protocol.file.allow=always", "submodule", "add", source, "component")
git("commit", "-qm", "Add submodule")
assert.equal((await captureReviewRevision(context)).clean, true)
await writeFile(path.join(context.repositoryPath, "component/component.txt"), "dirty\n")

await assert.rejects(() => captureReviewRevision(context), /submodule/i)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,7 @@ import path from "node:path"
import { readCanvasRepository } from "../read-canvas-repository/index.mjs"
import { runCommand } from "../run-command/index.mjs"
import { createPrivateStore } from "../private-store/index.mjs"

export class InvalidHubOrigin extends Error {}

function parseHubUrl(value, name) {
let origin
try {
origin = new URL(value)
} catch {
throw new InvalidHubOrigin(`${name} must be an HTTP or HTTPS origin without credentials.`)
}
if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password) {
throw new InvalidHubOrigin(`${name} must be an HTTP or HTTPS origin without credentials.`)
}
if (
origin.protocol === "http:" &&
origin.hostname !== "localhost" &&
origin.hostname !== "127.0.0.1" &&
!origin.hostname.endsWith(".localhost")
) {
throw new InvalidHubOrigin("Remote Replica Hub connections require HTTPS.")
}
return origin
}

export function normalizeHubOrigin(value, name = "REPLICA_HUB_ORIGIN") {
const origin = parseHubUrl(value, name)
if (origin.pathname !== "/" || origin.search || origin.hash) {
throw new InvalidHubOrigin(`${name} must be an HTTP or HTTPS origin without credentials.`)
}
return origin.origin
}

export function normalizeSelectedHubOrigin(value) {
return parseHubUrl(value, "Hub URL").origin
}
import { normalizeHubOrigin } from "../normalize-hub-origin/index.mjs"

function registrationReason(value) {
if (value == null) return undefined
Expand Down Expand Up @@ -299,6 +265,10 @@ export function createHubClient(context, options = {}) {
return {
origin: origin.origin,
checkout,
async connectionKey(repository) {
const credential = await credentialFor(repository)
return credential ? connectionIdentity(credential) : null
},
async status() {
const target = await checkout()
if (!target.repository) {
Expand Down Expand Up @@ -340,7 +310,9 @@ export function createHubClient(context, options = {}) {
return {
state: "undiscovered",
repositoryAuthorized: true,
reviewConnectionId: connectionIdentity(credential),
canPublish: connection.canPublish,
viewerLogin: connection.viewerLogin,
repository: target.repository,
commitSha: target.commitSha,
message: "Connected to this repository. Open a named branch for Builds and reviews.",
Expand All @@ -367,7 +339,9 @@ export function createHubClient(context, options = {}) {
return {
state: "undiscovered",
repositoryAuthorized: true,
reviewConnectionId: connectionIdentity(credential),
canPublish: connection.canPublish,
viewerLogin: connection.viewerLogin,
repository: target.repository,
branch: target.branch,
commitSha: target.commitSha,
Expand Down Expand Up @@ -418,6 +392,7 @@ export function createHubClient(context, options = {}) {
return {
state: "connected",
repositoryAuthorized: true,
reviewConnectionId: connectionIdentity(credential),
repository: target.repository,
branch: target.branch,
commitSha: target.commitSha,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import path from "node:path"
import { describe, it } from "node:test"

import { createCanvasContext, currentCanvasContext } from "../create-canvas-context/index.mjs"
import {
createHubClient,
InvalidHubOrigin,
normalizeHubOrigin,
normalizeSelectedHubOrigin,
} from "./index.mjs"
import { InvalidHubOrigin } from "../invalid-hub-origin/index.mjs"
import { normalizeHubOrigin } from "../normalize-hub-origin/index.mjs"
import { normalizeSelectedHubOrigin } from "../normalize-selected-hub-origin/index.mjs"

import { createHubClient } from "./index.mjs"

describe("normalizeSelectedHubOrigin()", () => {
it("converts manually selected Hub URLs to origins and preserves ports", () => {
Expand Down
Loading
Loading