From e7e5e12da4275addd628b8372264feecb49b6383 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:51:03 +0000 Subject: [PATCH 1/3] fix(shell): surface clone source in workspace context --- .../api/src/services/terminal-sessions.ts | 5 +- packages/api/tests/terminal-sessions.test.ts | 2 +- .../app/src/docker-git/open-project-ssh.ts | 9 +- .../src/web/app-ready-controller-context.ts | 4 +- .../src/web/app-ready-ssh-link-terminal.ts | 4 +- .../tests/docker-git/open-project-ssh.test.ts | 4 +- packages/terminal/src/core/index.ts | 1 + .../src/core/project-terminal-label.ts | 134 ++++++++++++++++++ .../tests/core/project-terminal-label.test.ts | 31 ++++ scripts/e2e/clone-auto-open-ssh.sh | 4 +- 10 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 packages/terminal/src/core/project-terminal-label.ts create mode 100644 packages/terminal/tests/core/project-terminal-label.test.ts diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 800df89b..e3e26beb 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -27,6 +27,7 @@ import { appendTerminalOutput, createTerminalImagePastePlan, emptyTerminalOutputBuffer, + projectTerminalLabel, renderTerminalOutputBuffer, terminalImagePasteDirectory, type TerminalImagePastePayload, @@ -1399,7 +1400,7 @@ export const createTerminalSession = ( const session = yield* _(registerRecord( resolvedProjectId, project.projectKey, - project.displayName, + projectTerminalLabel(project), prepared, projectItem.containerName, projectItem.targetDir, @@ -1421,7 +1422,7 @@ export const createTerminalSession = ( const session = yield* _(registerRecord( resolvedProjectId, startedProject.projectKey, - startedProject.displayName, + projectTerminalLabel(startedProject), prepared, reachableProjectItem.containerName, reachableProjectItem.targetDir, diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 42ad86d2..00083da2 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -414,7 +414,7 @@ describe("terminal sessions service", () => { status: "ready" }) await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({ - projectDisplayName: displayName, + projectDisplayName: "org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7", projectKey, session: { id: second.session.id, diff --git a/packages/app/src/docker-git/open-project-ssh.ts b/packages/app/src/docker-git/open-project-ssh.ts index 2881e6a8..174c55f4 100644 --- a/packages/app/src/docker-git/open-project-ssh.ts +++ b/packages/app/src/docker-git/open-project-ssh.ts @@ -1,6 +1,7 @@ import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" +import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core" import { Duration, Effect } from "effect" import { createProjectTerminalSession, upProject } from "./api-client.js" @@ -156,7 +157,7 @@ const resolveHostSshLaunchSpec = ( const writeProjectSshHeader = (item: ProjectItem): Effect.Effect => Effect.sync(() => { - writeToTerminal(`\n[docker-git] SSH terminal: ${item.displayName}\n`) + writeToTerminal(`\n[docker-git] SSH terminal: ${projectTerminalLabel(item)}\n`) writeToTerminal(`[docker-git] ${item.sshCommand}\n\n`) }) @@ -203,9 +204,9 @@ export const openResolvedProjectSshWithUpEffect = ( ) => Effect.gen(function*(_) { const writeProgress = deps.writeProgress ?? writeProjectOpenProgress - yield* _(writeProgress(`Starting project before SSH: ${item.displayName}`)) + yield* _(writeProgress(`Starting project before SSH: ${projectTerminalLabel(item)}`)) const refreshedItem = yield* _(deps.upProject(item.projectDir)) - yield* _(writeProgress(`Opening SSH terminal: ${(refreshedItem ?? item).displayName}`)) + yield* _(writeProgress(`Opening SSH terminal: ${projectTerminalLabel(refreshedItem ?? item)}`)) yield* _(deps.openProjectSsh(refreshedItem ?? item)) }) @@ -241,7 +242,7 @@ export const openResolvedProjectSshViaController = (item: ProjectItem) => createSession: (projectId) => createProjectTerminalSession(projectId), attach: (project, session) => attachTerminalSession({ - header: `SSH terminal: ${project.displayName}`, + header: `SSH terminal: ${projectTerminalLabel(project)}`, session, websocketPath: `/projects/${encodeURIComponent(project.projectDir)}/terminal-sessions/${ encodeURIComponent(session.id) diff --git a/packages/app/src/web/app-ready-controller-context.ts b/packages/app/src/web/app-ready-controller-context.ts index 74435a56..a968239f 100644 --- a/packages/app/src/web/app-ready-controller-context.ts +++ b/packages/app/src/web/app-ready-controller-context.ts @@ -1,3 +1,5 @@ +import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core" + import type { DashboardData } from "./api.js" import { createActionContext } from "./app-ready-actions.js" import type { ReadyState } from "./app-ready-hooks.js" @@ -23,7 +25,7 @@ export const createReadyActionContext = ( refreshDashboard, selectedProjectId: state.selectedProjectId, selectedProjectKey: selectedProjectSummary?.projectKey ?? null, - selectedProjectName: selectedProjectSummary?.displayName ?? null, + selectedProjectName: selectedProjectSummary === undefined ? null : projectTerminalLabel(selectedProjectSummary), setActionPrompt: state.setActionPrompt, setActiveScreen: state.setActiveScreen, setAuthSnapshot: state.setAuthSnapshot, diff --git a/packages/app/src/web/app-ready-ssh-link-terminal.ts b/packages/app/src/web/app-ready-ssh-link-terminal.ts index 4d2099f4..cf5790a8 100644 --- a/packages/app/src/web/app-ready-ssh-link-terminal.ts +++ b/packages/app/src/web/app-ready-ssh-link-terminal.ts @@ -1,3 +1,5 @@ +import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core" + import type { BrowserActionContext } from "./actions-shared.js" import type { TerminalSession } from "./api-types.js" import type { DashboardProject } from "./app-ready-ssh-link-core.js" @@ -135,7 +137,7 @@ const buildProjectTerminalSession = ( buildProjectActiveTerminalSession({ onExit: args.actionContext.reloadDashboard, onReady: args.actionContext.reloadDashboard, - projectDisplayName: project.displayName, + projectDisplayName: projectTerminalLabel(project), projectId: project.id, projectKey: project.projectKey, session diff --git a/packages/app/tests/docker-git/open-project-ssh.test.ts b/packages/app/tests/docker-git/open-project-ssh.test.ts index 29a3c413..c31cff4d 100644 --- a/packages/app/tests/docker-git/open-project-ssh.test.ts +++ b/packages/app/tests/docker-git/open-project-ssh.test.ts @@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => { }) const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item)) expect(events).toEqual([ - "progress:Starting project before SSH: org/repo", + "progress:Starting project before SSH: org/repo | source https://github.com/org/repo.git | container dg-repo", "up:/controller/org/repo/issue-9", - "progress:Opening SSH terminal: org/repo", + "progress:Opening SSH terminal: org/repo | source https://github.com/org/repo.git | container dg-repo", "open:ssh -p 2299 dev@127.0.0.1" ]) })) diff --git a/packages/terminal/src/core/index.ts b/packages/terminal/src/core/index.ts index cf242a9d..28678e56 100644 --- a/packages/terminal/src/core/index.ts +++ b/packages/terminal/src/core/index.ts @@ -1,2 +1,3 @@ export * from "./image-paste.js" export * from "./output-buffer.js" +export * from "./project-terminal-label.js" diff --git a/packages/terminal/src/core/project-terminal-label.ts b/packages/terminal/src/core/project-terminal-label.ts new file mode 100644 index 00000000..0b0f126d --- /dev/null +++ b/packages/terminal/src/core/project-terminal-label.ts @@ -0,0 +1,134 @@ +export type ProjectTerminalLabelInput = { + readonly containerName?: string | undefined + readonly displayName: string + readonly repoRef: string + readonly repoUrl: string +} + +const issueRefPattern = /^issue-(\d+)$/u +const githubPullRefPattern = /^refs\/pull\/(\d+)\/head$/u +const gitlabMergeRequestRefPattern = /^refs\/merge-requests\/(\d+)\/head$/u + +const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value + +const readPathPart = (value: string | undefined): string | null => { + const trimmed = value?.trim() ?? "" + return trimmed.length > 0 ? trimmed : null +} + +const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray | null => { + const trimmed = repoUrl.trim() + const httpsPrefix = "https://github.com/" + const sshUrlPrefix = "ssh://git@github.com/" + const sshScpPrefix = "git@github.com:" + if (trimmed.startsWith(httpsPrefix)) { + return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0) + } + if (trimmed.startsWith(sshUrlPrefix)) { + return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0) + } + if (trimmed.startsWith(sshScpPrefix)) { + return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0) + } + return null +} + +const githubRepositoryPath = (repoUrl: string): string | null => { + const parts = splitGitHubRemotePath(repoUrl) + const owner = readPathPart(parts?.[0]) + const repoRaw = readPathPart(parts?.[1]) + if (owner === null || repoRaw === null) { + return null + } + return `${owner}/${stripGitSuffix(repoRaw)}` +} + +const sourceUrlForContext = (repoUrl: string, path: string): string | null => { + const repoPath = githubRepositoryPath(repoUrl) + return repoPath === null ? null : `https://github.com/${repoPath}/${path}` +} + +const renderIssueContext = (repoUrl: string, issueId: string): string => { + const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`) + return issueUrl === null ? `issue #${issueId}` : `issue #${issueId} (${issueUrl})` +} + +const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => { + const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`) + return pullRequestUrl === null ? `PR #${pullRequestId}` : `PR #${pullRequestId} (${pullRequestUrl})` +} + +const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}` + +const renderSourceContext = (repoUrl: string, repoRef: string): string => { + const trimmedRef = repoRef.trim() + return trimmedRef.length === 0 || trimmedRef === "main" + ? `source ${repoUrl.trim()}` + : `source ${repoUrl.trim()} (${trimmedRef})` +} + +const renderWorkspaceContext = ( + repoUrl: string, + repoRef: string +): string => { + const issueMatch = issueRefPattern.exec(repoRef) + if (issueMatch !== null) { + const issueId = issueMatch[1] + return issueId === undefined ? renderSourceContext(repoUrl, repoRef) : renderIssueContext(repoUrl, issueId) + } + const pullMatch = githubPullRefPattern.exec(repoRef) + if (pullMatch !== null) { + const pullRequestId = pullMatch[1] + return pullRequestId === undefined + ? renderSourceContext(repoUrl, repoRef) + : renderPullRequestContext(repoUrl, pullRequestId) + } + const mergeRequestMatch = gitlabMergeRequestRefPattern.exec(repoRef) + if (mergeRequestMatch !== null) { + const mergeRequestId = mergeRequestMatch[1] + return mergeRequestId === undefined + ? renderSourceContext(repoUrl, repoRef) + : renderMergeRequestContext(mergeRequestId) + } + return renderSourceContext(repoUrl, repoRef) +} + +const appendNonEmpty = (parts: ReadonlyArray, value: string): ReadonlyArray => { + const trimmed = value.trim() + return trimmed.length === 0 ? parts : [...parts, trimmed] +} + +/** + * Builds the terminal-facing project label with source workspace context. + * + * @param project - Project identity returned by the docker-git API. + * @returns A deterministic label for SSH terminal headers and ready messages. + * + * @pure true + * @effect none + * @invariant issue refs include an issue marker; PR refs include a PR marker; labels never omit displayName. + * @precondition project.displayName identifies the repository or fallback project label. + * @postcondition result contains displayName, workspace source context, and non-empty containerName when present. + * @complexity O(n) where n = |repoUrl| + |repoRef| + * @throws Never + */ +// CHANGE: surface clone-source context in SSH terminal labels +// WHY: terminal headers must identify issue/PR source and container instead of only the repo path +// QUOTE(ТЗ): "надо писать какой Issues какой PR вообещ что за конетейнер" +// REF: issue-370 +// SOURCE: n/a +// FORMAT THEOREM: forall p: label(p) contains displayName(p) and context(repoUrl(p), repoRef(p)) +// PURITY: CORE +// EFFECT: none +// INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty +// COMPLEXITY: O(n) +export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => { + const displayName = project.displayName.trim() + const baseName = displayName.length === 0 ? project.repoUrl.trim() : displayName + const withContext = appendNonEmpty([baseName], renderWorkspaceContext(project.repoUrl, project.repoRef)) + const containerName = project.containerName?.trim() ?? "" + const withContainer = containerName.length === 0 + ? withContext + : appendNonEmpty(withContext, `container ${containerName}`) + return withContainer.join(" | ") +} diff --git a/packages/terminal/tests/core/project-terminal-label.test.ts b/packages/terminal/tests/core/project-terminal-label.test.ts new file mode 100644 index 00000000..0b6da1bd --- /dev/null +++ b/packages/terminal/tests/core/project-terminal-label.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" + +import { projectTerminalLabel } from "../../src/core/project-terminal-label.js" + +describe("projectTerminalLabel", () => { + it("renders GitHub issue source context and container identity", () => { + expect(projectTerminalLabel({ + containerName: "dg-repo-issue-7", + displayName: "org/repo", + repoRef: "issue-7", + repoUrl: "https://github.com/org/repo.git" + })).toBe("org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7") + }) + + it("renders GitHub pull request source context from pull refs", () => { + expect(projectTerminalLabel({ + containerName: "dg-repo-pr-42", + displayName: "org/repo", + repoRef: "refs/pull/42/head", + repoUrl: "git@github.com:org/repo.git" + })).toBe("org/repo | PR #42 (https://github.com/org/repo/pull/42) | container dg-repo-pr-42") + }) + + it("renders repository source context for ordinary refs", () => { + expect(projectTerminalLabel({ + displayName: "org/repo", + repoRef: "feature-x", + repoUrl: "https://github.com/org/repo.git" + })).toBe("org/repo | source https://github.com/org/repo.git (feature-x)") + }) +}) diff --git a/scripts/e2e/clone-auto-open-ssh.sh b/scripts/e2e/clone-auto-open-ssh.sh index d18df1dd..c73dc9aa 100755 --- a/scripts/e2e/clone-auto-open-ssh.sh +++ b/scripts/e2e/clone-auto-open-ssh.sh @@ -246,8 +246,8 @@ fi grep -Fq -- "Project created: octocat/hello-world" "$CLONE_LOG" \ || fail "expected clone log to confirm project creation" -grep -Fq -- "SSH terminal: octocat/hello-world" "$CLONE_LOG" \ - || fail "expected clone log to show SSH auto-open header" +grep -Fq -- "SSH terminal: octocat/hello-world | issue #1 (https://github.com/octocat/Hello-World/issues/1) | container $CONTAINER_NAME" "$CLONE_LOG" \ + || fail "expected clone log to show SSH auto-open header with issue URL and container" [[ -f "$SSH_INVOCATION_LOG" ]] || fail "expected ssh wrapper to be invoked" grep -Fq -- "<-tt>" "$SSH_INVOCATION_LOG" || fail "expected ssh to request a tty" From d02ecf8c3ce34d1d7db982e91dc39eef03263a64 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:27:09 +0000 Subject: [PATCH 2/3] test(core): cover terminal label invariants --- .../src/core/project-terminal-label.ts | 37 +++-- .../tests/core/project-terminal-label.test.ts | 133 ++++++++++++++++++ 2 files changed, 151 insertions(+), 19 deletions(-) diff --git a/packages/terminal/src/core/project-terminal-label.ts b/packages/terminal/src/core/project-terminal-label.ts index 0b0f126d..95597917 100644 --- a/packages/terminal/src/core/project-terminal-label.ts +++ b/packages/terminal/src/core/project-terminal-label.ts @@ -5,9 +5,7 @@ export type ProjectTerminalLabelInput = { readonly repoUrl: string } -const issueRefPattern = /^issue-(\d+)$/u -const githubPullRefPattern = /^refs\/pull\/(\d+)\/head$/u -const gitlabMergeRequestRefPattern = /^refs\/merge-requests\/(\d+)\/head$/u +const decimalDigitsPattern = /^\d+$/u const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value @@ -67,28 +65,29 @@ const renderSourceContext = (repoUrl: string, repoRef: string): string => { : `source ${repoUrl.trim()} (${trimmedRef})` } +const parseWrappedNumericRef = (value: string, prefix: string, suffix: string): string | null => { + if (!value.startsWith(prefix) || !value.endsWith(suffix)) { + return null + } + const id = value.slice(prefix.length, value.length - suffix.length) + return decimalDigitsPattern.test(id) ? id : null +} + const renderWorkspaceContext = ( repoUrl: string, repoRef: string ): string => { - const issueMatch = issueRefPattern.exec(repoRef) - if (issueMatch !== null) { - const issueId = issueMatch[1] - return issueId === undefined ? renderSourceContext(repoUrl, repoRef) : renderIssueContext(repoUrl, issueId) + const issueId = parseWrappedNumericRef(repoRef, "issue-", "") + if (issueId !== null) { + return renderIssueContext(repoUrl, issueId) } - const pullMatch = githubPullRefPattern.exec(repoRef) - if (pullMatch !== null) { - const pullRequestId = pullMatch[1] - return pullRequestId === undefined - ? renderSourceContext(repoUrl, repoRef) - : renderPullRequestContext(repoUrl, pullRequestId) + const pullRequestId = parseWrappedNumericRef(repoRef, "refs/pull/", "/head") + if (pullRequestId !== null) { + return renderPullRequestContext(repoUrl, pullRequestId) } - const mergeRequestMatch = gitlabMergeRequestRefPattern.exec(repoRef) - if (mergeRequestMatch !== null) { - const mergeRequestId = mergeRequestMatch[1] - return mergeRequestId === undefined - ? renderSourceContext(repoUrl, repoRef) - : renderMergeRequestContext(mergeRequestId) + const mergeRequestId = parseWrappedNumericRef(repoRef, "refs/merge-requests/", "/head") + if (mergeRequestId !== null) { + return renderMergeRequestContext(mergeRequestId) } return renderSourceContext(repoUrl, repoRef) } diff --git a/packages/terminal/tests/core/project-terminal-label.test.ts b/packages/terminal/tests/core/project-terminal-label.test.ts index 0b6da1bd..bcaf85e2 100644 --- a/packages/terminal/tests/core/project-terminal-label.test.ts +++ b/packages/terminal/tests/core/project-terminal-label.test.ts @@ -1,7 +1,59 @@ +import * as fc from "fast-check" import { describe, expect, it } from "vitest" import { projectTerminalLabel } from "../../src/core/project-terminal-label.js" +const asciiCodeToCharacter = (code: number): string => String.fromCodePoint(code) + +const alphaNumericCharacterArbitrary = fc.oneof( + fc.integer({ max: 57, min: 48 }), + fc.integer({ max: 90, min: 65 }), + fc.integer({ max: 122, min: 97 }) +).map((code) => asciiCodeToCharacter(code)) + +const pathCharacterArbitrary = fc.oneof(alphaNumericCharacterArbitrary, fc.constant("-")) + +const labelCharacterArbitrary = fc.oneof( + pathCharacterArbitrary, + fc.constant("_"), + fc.constant("."), + fc.constant("/") +) + +const gitHubPathSegmentArbitrary = fc.tuple( + alphaNumericCharacterArbitrary, + fc.array(pathCharacterArbitrary, { maxLength: 12 }) +).map(([head, tail]) => `${head}${tail.join("")}`) + +const readableLabelArbitrary = fc.array(labelCharacterArbitrary, { + maxLength: 24, + minLength: 1 +}).map((characters) => characters.join("")) + +const paddedReadableLabelArbitrary = fc.tuple( + fc.constantFrom("", " ", " "), + readableLabelArbitrary, + fc.constantFrom("", " ", " ") +).map(([left, value, right]) => `${left}${value}${right}`) + +const repositoryArbitrary = fc.record({ + owner: gitHubPathSegmentArbitrary, + repo: gitHubPathSegmentArbitrary +}) + +type GeneratedRepository = { + readonly owner: string + readonly repo: string +} + +const refIdArbitrary = fc.integer({ max: 1_000_000, min: 1 }) + +const assertRepositoryRefIdProperty = ( + assertion: (repository: GeneratedRepository, refId: number) => void +): void => { + fc.assert(fc.property(repositoryArbitrary, refIdArbitrary, assertion)) +} + describe("projectTerminalLabel", () => { it("renders GitHub issue source context and container identity", () => { expect(projectTerminalLabel({ @@ -28,4 +80,85 @@ describe("projectTerminalLabel", () => { repoUrl: "https://github.com/org/repo.git" })).toBe("org/repo | source https://github.com/org/repo.git (feature-x)") }) + + it("preserves issue markers and GitHub issue URLs for generated issue refs", () => { + assertRepositoryRefIdProperty(({ owner, repo }, issueId) => { + const label = projectTerminalLabel({ + displayName: `${owner}/${repo}`, + repoRef: `issue-${issueId}`, + repoUrl: `https://github.com/${owner}/${repo}.git` + }) + + expect(label).toBe( + `${owner}/${repo} | issue #${issueId} (https://github.com/${owner}/${repo}/issues/${issueId})` + ) + }) + }) + + it("preserves PR and MR markers for generated review refs", () => { + fc.assert( + fc.property( + repositoryArbitrary, + refIdArbitrary, + fc.constantFrom("pull", "merge-request"), + ({ owner, repo }, reviewId, refKind) => { + const repoUrl = `git@github.com:${owner}/${repo}.git` + const label = projectTerminalLabel({ + displayName: `${owner}/${repo}`, + repoRef: refKind === "pull" ? `refs/pull/${reviewId}/head` : `refs/merge-requests/${reviewId}/head`, + repoUrl + }) + + expect(label).toBe( + refKind === "pull" + ? `${owner}/${repo} | PR #${reviewId} (https://github.com/${owner}/${repo}/pull/${reviewId})` + : `${owner}/${repo} | MR #${reviewId}` + ) + } + ) + ) + }) + + it("uses repoUrl as the base label when displayName is blank", () => { + fc.assert( + fc.property(repositoryArbitrary, fc.constantFrom("", " ", " "), ({ owner, repo }, displayName) => { + const repoUrl = `https://github.com/${owner}/${repo}.git` + + expect(projectTerminalLabel({ + displayName, + repoRef: "main", + repoUrl + })).toBe(`${repoUrl} | source ${repoUrl}`) + }) + ) + }) + + it("normalizes empty and main refs to source context without ref suffix", () => { + fc.assert( + fc.property(repositoryArbitrary, fc.constantFrom("", " ", " ", "main"), ({ owner, repo }, repoRef) => { + const repoUrl = `https://github.com/${owner}/${repo}.git` + + expect(projectTerminalLabel({ + displayName: `${owner}/${repo}`, + repoRef, + repoUrl + })).toBe(`${owner}/${repo} | source ${repoUrl}`) + }) + ) + }) + + it("preserves non-empty container names after trimming", () => { + fc.assert( + fc.property(repositoryArbitrary, paddedReadableLabelArbitrary, ({ owner, repo }, containerName) => { + const label = projectTerminalLabel({ + containerName, + displayName: `${owner}/${repo}`, + repoRef: "feature-x", + repoUrl: `https://github.com/${owner}/${repo}.git` + }) + + expect(label.endsWith(` | container ${containerName.trim()}`)).toBe(true) + }) + ) + }) }) From fd03a0f683b87d021141d32456247376fa1042c8 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:54:37 +0000 Subject: [PATCH 3/3] fix(shell): shorten terminal label context --- packages/api/tests/terminal-sessions.test.ts | 2 +- .../tests/docker-git/open-project-ssh.test.ts | 4 +- .../src/core/project-terminal-label.ts | 36 ++++--- .../tests/core/project-terminal-label.test.ts | 102 +++++++++++++----- scripts/e2e/clone-auto-open-ssh.sh | 2 +- 5 files changed, 101 insertions(+), 45 deletions(-) diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 00083da2..76a6beae 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -414,7 +414,7 @@ describe("terminal sessions service", () => { status: "ready" }) await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({ - projectDisplayName: "org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7", + projectDisplayName: "https://github.com/org/repo/issues/7 | container dg-repo-issue-7", projectKey, session: { id: second.session.id, diff --git a/packages/app/tests/docker-git/open-project-ssh.test.ts b/packages/app/tests/docker-git/open-project-ssh.test.ts index c31cff4d..5f80601d 100644 --- a/packages/app/tests/docker-git/open-project-ssh.test.ts +++ b/packages/app/tests/docker-git/open-project-ssh.test.ts @@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => { }) const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item)) expect(events).toEqual([ - "progress:Starting project before SSH: org/repo | source https://github.com/org/repo.git | container dg-repo", + "progress:Starting project before SSH: https://github.com/org/repo.git | container dg-repo", "up:/controller/org/repo/issue-9", - "progress:Opening SSH terminal: org/repo | source https://github.com/org/repo.git | container dg-repo", + "progress:Opening SSH terminal: https://github.com/org/repo.git | container dg-repo", "open:ssh -p 2299 dev@127.0.0.1" ]) })) diff --git a/packages/terminal/src/core/project-terminal-label.ts b/packages/terminal/src/core/project-terminal-label.ts index 95597917..17b41b63 100644 --- a/packages/terminal/src/core/project-terminal-label.ts +++ b/packages/terminal/src/core/project-terminal-label.ts @@ -48,21 +48,25 @@ const sourceUrlForContext = (repoUrl: string, path: string): string | null => { const renderIssueContext = (repoUrl: string, issueId: string): string => { const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`) - return issueUrl === null ? `issue #${issueId}` : `issue #${issueId} (${issueUrl})` + return issueUrl === null ? `issue #${issueId}` : issueUrl } const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => { const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`) - return pullRequestUrl === null ? `PR #${pullRequestId}` : `PR #${pullRequestId} (${pullRequestUrl})` + return pullRequestUrl === null ? `PR #${pullRequestId}` : pullRequestUrl } const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}` const renderSourceContext = (repoUrl: string, repoRef: string): string => { + const trimmedUrl = repoUrl.trim() const trimmedRef = repoRef.trim() + if (trimmedUrl.length === 0) { + return trimmedRef.length === 0 || trimmedRef === "main" ? "" : trimmedRef + } return trimmedRef.length === 0 || trimmedRef === "main" - ? `source ${repoUrl.trim()}` - : `source ${repoUrl.trim()} (${trimmedRef})` + ? trimmedUrl + : `${trimmedUrl} (${trimmedRef})` } const parseWrappedNumericRef = (value: string, prefix: string, suffix: string): string | null => { @@ -98,36 +102,38 @@ const appendNonEmpty = (parts: ReadonlyArray, value: string): ReadonlyAr } /** - * Builds the terminal-facing project label with source workspace context. + * Builds the terminal-facing project label with source link and container identity. * * @param project - Project identity returned by the docker-git API. * @returns A deterministic label for SSH terminal headers and ready messages. * * @pure true * @effect none - * @invariant issue refs include an issue marker; PR refs include a PR marker; labels never omit displayName. + * @invariant GitHub issue/PR refs prefer canonical source URLs; labels preserve non-empty containerName. * @precondition project.displayName identifies the repository or fallback project label. - * @postcondition result contains displayName, workspace source context, and non-empty containerName when present. + * @postcondition result contains workspace source link/context and non-empty containerName when present. * @complexity O(n) where n = |repoUrl| + |repoRef| * @throws Never */ -// CHANGE: surface clone-source context in SSH terminal labels -// WHY: terminal headers must identify issue/PR source and container instead of only the repo path -// QUOTE(ТЗ): "надо писать какой Issues какой PR вообещ что за конетейнер" +// CHANGE: keep SSH terminal labels to source link/context plus container identity +// WHY: verbose repository + issue text duplicates the source URL and crowds the terminal header +// QUOTE(ТЗ): "ссылки и название контейнера будет предостаточно" // REF: issue-370 // SOURCE: n/a -// FORMAT THEOREM: forall p: label(p) contains displayName(p) and context(repoUrl(p), repoRef(p)) +// FORMAT THEOREM: forall p: label(p) contains context(repoUrl(p), repoRef(p)) or containerName(p) // PURITY: CORE // EFFECT: none // INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty // COMPLEXITY: O(n) export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => { - const displayName = project.displayName.trim() - const baseName = displayName.length === 0 ? project.repoUrl.trim() : displayName - const withContext = appendNonEmpty([baseName], renderWorkspaceContext(project.repoUrl, project.repoRef)) + const withContext = appendNonEmpty([], renderWorkspaceContext(project.repoUrl, project.repoRef)) const containerName = project.containerName?.trim() ?? "" const withContainer = containerName.length === 0 ? withContext : appendNonEmpty(withContext, `container ${containerName}`) - return withContainer.join(" | ") + if (withContainer.length > 0) { + return withContainer.join(" | ") + } + const displayName = project.displayName.trim() + return displayName.length === 0 ? project.repoUrl.trim() : displayName } diff --git a/packages/terminal/tests/core/project-terminal-label.test.ts b/packages/terminal/tests/core/project-terminal-label.test.ts index bcaf85e2..bcfaa7c4 100644 --- a/packages/terminal/tests/core/project-terminal-label.test.ts +++ b/packages/terminal/tests/core/project-terminal-label.test.ts @@ -36,6 +36,10 @@ const paddedReadableLabelArbitrary = fc.tuple( fc.constantFrom("", " ", " ") ).map(([left, value, right]) => `${left}${value}${right}`) +const blankLabelArbitrary = fc.constantFrom("", " ", " ") + +const emptyOrMainRefArbitrary = fc.constantFrom("", " ", " ", "main") + const repositoryArbitrary = fc.record({ owner: gitHubPathSegmentArbitrary, repo: gitHubPathSegmentArbitrary @@ -54,23 +58,34 @@ const assertRepositoryRefIdProperty = ( fc.assert(fc.property(repositoryArbitrary, refIdArbitrary, assertion)) } +const projectFeatureLabelWithContainer = ( + { owner, repo }: GeneratedRepository, + containerName: string +): string => + projectTerminalLabel({ + containerName, + displayName: `${owner}/${repo}`, + repoRef: "feature-x", + repoUrl: `https://github.com/${owner}/${repo}.git` + }) + describe("projectTerminalLabel", () => { - it("renders GitHub issue source context and container identity", () => { + it("renders GitHub issue source URL and container identity", () => { expect(projectTerminalLabel({ containerName: "dg-repo-issue-7", displayName: "org/repo", repoRef: "issue-7", repoUrl: "https://github.com/org/repo.git" - })).toBe("org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7") + })).toBe("https://github.com/org/repo/issues/7 | container dg-repo-issue-7") }) - it("renders GitHub pull request source context from pull refs", () => { + it("renders GitHub pull request source URL from pull refs", () => { expect(projectTerminalLabel({ containerName: "dg-repo-pr-42", displayName: "org/repo", repoRef: "refs/pull/42/head", repoUrl: "git@github.com:org/repo.git" - })).toBe("org/repo | PR #42 (https://github.com/org/repo/pull/42) | container dg-repo-pr-42") + })).toBe("https://github.com/org/repo/pull/42 | container dg-repo-pr-42") }) it("renders repository source context for ordinary refs", () => { @@ -78,10 +93,10 @@ describe("projectTerminalLabel", () => { displayName: "org/repo", repoRef: "feature-x", repoUrl: "https://github.com/org/repo.git" - })).toBe("org/repo | source https://github.com/org/repo.git (feature-x)") + })).toBe("https://github.com/org/repo.git (feature-x)") }) - it("preserves issue markers and GitHub issue URLs for generated issue refs", () => { + it("property-based invariant: issue-N mapping generates canonical GitHub issue URLs", () => { assertRepositoryRefIdProperty(({ owner, repo }, issueId) => { const label = projectTerminalLabel({ displayName: `${owner}/${repo}`, @@ -89,13 +104,11 @@ describe("projectTerminalLabel", () => { repoUrl: `https://github.com/${owner}/${repo}.git` }) - expect(label).toBe( - `${owner}/${repo} | issue #${issueId} (https://github.com/${owner}/${repo}/issues/${issueId})` - ) + expect(label).toBe(`https://github.com/${owner}/${repo}/issues/${issueId}`) }) }) - it("preserves PR and MR markers for generated review refs", () => { + it("property-based invariant: PR/MR markers generate review source context", () => { fc.assert( fc.property( repositoryArbitrary, @@ -111,54 +124,91 @@ describe("projectTerminalLabel", () => { expect(label).toBe( refKind === "pull" - ? `${owner}/${repo} | PR #${reviewId} (https://github.com/${owner}/${repo}/pull/${reviewId})` - : `${owner}/${repo} | MR #${reviewId}` + ? `https://github.com/${owner}/${repo}/pull/${reviewId}` + : `MR #${reviewId}` ) } ) ) }) - it("uses repoUrl as the base label when displayName is blank", () => { + it("property-based invariant: displayName/repoUrl fallback is deterministic without source context", () => { + fc.assert( + fc.property( + paddedReadableLabelArbitrary, + blankLabelArbitrary, + emptyOrMainRefArbitrary, + (displayName, repoUrl, repoRef) => { + expect(projectTerminalLabel({ + displayName, + repoRef, + repoUrl + })).toBe(displayName.trim()) + } + ) + ) + fc.assert( - fc.property(repositoryArbitrary, fc.constantFrom("", " ", " "), ({ owner, repo }, displayName) => { + fc.property( + paddedReadableLabelArbitrary, + blankLabelArbitrary, + emptyOrMainRefArbitrary, + (repoUrl, displayName, repoRef) => { + expect(projectTerminalLabel({ + displayName, + repoRef, + repoUrl + })).toBe(repoUrl.trim()) + } + ) + ) + }) + + it("property-based invariant: repoUrl fallback is used when displayName is blank", () => { + fc.assert( + fc.property(repositoryArbitrary, blankLabelArbitrary, ({ owner, repo }, displayName) => { const repoUrl = `https://github.com/${owner}/${repo}.git` expect(projectTerminalLabel({ displayName, repoRef: "main", repoUrl - })).toBe(`${repoUrl} | source ${repoUrl}`) + })).toBe(repoUrl) }) ) }) - it("normalizes empty and main refs to source context without ref suffix", () => { + it("property-based invariant: empty/main ref handling omits ref suffix", () => { fc.assert( - fc.property(repositoryArbitrary, fc.constantFrom("", " ", " ", "main"), ({ owner, repo }, repoRef) => { + fc.property(repositoryArbitrary, emptyOrMainRefArbitrary, ({ owner, repo }, repoRef) => { const repoUrl = `https://github.com/${owner}/${repo}.git` expect(projectTerminalLabel({ displayName: `${owner}/${repo}`, repoRef, repoUrl - })).toBe(`${owner}/${repo} | source ${repoUrl}`) + })).toBe(repoUrl) }) ) }) - it("preserves non-empty container names after trimming", () => { + it("property-based invariant: container handling preserves non-empty container names after trimming", () => { fc.assert( - fc.property(repositoryArbitrary, paddedReadableLabelArbitrary, ({ owner, repo }, containerName) => { - const label = projectTerminalLabel({ - containerName, - displayName: `${owner}/${repo}`, - repoRef: "feature-x", - repoUrl: `https://github.com/${owner}/${repo}.git` - }) + fc.property(repositoryArbitrary, paddedReadableLabelArbitrary, (repository, containerName) => { + const label = projectFeatureLabelWithContainer(repository, containerName) expect(label.endsWith(` | container ${containerName.trim()}`)).toBe(true) }) ) }) + + it("property-based invariant: container handling omits blank container names", () => { + fc.assert( + fc.property(repositoryArbitrary, blankLabelArbitrary, (repository, containerName) => { + const label = projectFeatureLabelWithContainer(repository, containerName) + + expect(label).not.toContain("container ") + }) + ) + }) }) diff --git a/scripts/e2e/clone-auto-open-ssh.sh b/scripts/e2e/clone-auto-open-ssh.sh index c73dc9aa..e933fae8 100755 --- a/scripts/e2e/clone-auto-open-ssh.sh +++ b/scripts/e2e/clone-auto-open-ssh.sh @@ -246,7 +246,7 @@ fi grep -Fq -- "Project created: octocat/hello-world" "$CLONE_LOG" \ || fail "expected clone log to confirm project creation" -grep -Fq -- "SSH terminal: octocat/hello-world | issue #1 (https://github.com/octocat/Hello-World/issues/1) | container $CONTAINER_NAME" "$CLONE_LOG" \ +grep -Fq -- "SSH terminal: https://github.com/octocat/Hello-World/issues/1 | container $CONTAINER_NAME" "$CLONE_LOG" \ || fail "expected clone log to show SSH auto-open header with issue URL and container" [[ -f "$SSH_INVOCATION_LOG" ]] || fail "expected ssh wrapper to be invoked"