Skip to content

Commit e7e5e12

Browse files
committed
fix(shell): surface clone source in workspace context
1 parent fd45823 commit e7e5e12

10 files changed

Lines changed: 185 additions & 13 deletions

File tree

packages/api/src/services/terminal-sessions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
appendTerminalOutput,
2828
createTerminalImagePastePlan,
2929
emptyTerminalOutputBuffer,
30+
projectTerminalLabel,
3031
renderTerminalOutputBuffer,
3132
terminalImagePasteDirectory,
3233
type TerminalImagePastePayload,
@@ -1399,7 +1400,7 @@ export const createTerminalSession = (
13991400
const session = yield* _(registerRecord(
14001401
resolvedProjectId,
14011402
project.projectKey,
1402-
project.displayName,
1403+
projectTerminalLabel(project),
14031404
prepared,
14041405
projectItem.containerName,
14051406
projectItem.targetDir,
@@ -1421,7 +1422,7 @@ export const createTerminalSession = (
14211422
const session = yield* _(registerRecord(
14221423
resolvedProjectId,
14231424
startedProject.projectKey,
1424-
startedProject.displayName,
1425+
projectTerminalLabel(startedProject),
14251426
prepared,
14261427
reachableProjectItem.containerName,
14271428
reachableProjectItem.targetDir,

packages/api/tests/terminal-sessions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ describe("terminal sessions service", () => {
414414
status: "ready"
415415
})
416416
await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({
417-
projectDisplayName: displayName,
417+
projectDisplayName: "org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7",
418418
projectKey,
419419
session: {
420420
id: second.session.id,

packages/app/src/docker-git/open-project-ssh.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { PlatformError } from "@effect/platform/Error"
22
import * as FileSystem from "@effect/platform/FileSystem"
33
import * as Path from "@effect/platform/Path"
4+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
45
import { Duration, Effect } from "effect"
56

67
import { createProjectTerminalSession, upProject } from "./api-client.js"
@@ -156,7 +157,7 @@ const resolveHostSshLaunchSpec = (
156157

157158
const writeProjectSshHeader = (item: ProjectItem): Effect.Effect<void> =>
158159
Effect.sync(() => {
159-
writeToTerminal(`\n[docker-git] SSH terminal: ${item.displayName}\n`)
160+
writeToTerminal(`\n[docker-git] SSH terminal: ${projectTerminalLabel(item)}\n`)
160161
writeToTerminal(`[docker-git] ${item.sshCommand}\n\n`)
161162
})
162163

@@ -203,9 +204,9 @@ export const openResolvedProjectSshWithUpEffect = <E, R>(
203204
) =>
204205
Effect.gen(function*(_) {
205206
const writeProgress = deps.writeProgress ?? writeProjectOpenProgress
206-
yield* _(writeProgress(`Starting project before SSH: ${item.displayName}`))
207+
yield* _(writeProgress(`Starting project before SSH: ${projectTerminalLabel(item)}`))
207208
const refreshedItem = yield* _(deps.upProject(item.projectDir))
208-
yield* _(writeProgress(`Opening SSH terminal: ${(refreshedItem ?? item).displayName}`))
209+
yield* _(writeProgress(`Opening SSH terminal: ${projectTerminalLabel(refreshedItem ?? item)}`))
209210
yield* _(deps.openProjectSsh(refreshedItem ?? item))
210211
})
211212

@@ -241,7 +242,7 @@ export const openResolvedProjectSshViaController = (item: ProjectItem) =>
241242
createSession: (projectId) => createProjectTerminalSession(projectId),
242243
attach: (project, session) =>
243244
attachTerminalSession({
244-
header: `SSH terminal: ${project.displayName}`,
245+
header: `SSH terminal: ${projectTerminalLabel(project)}`,
245246
session,
246247
websocketPath: `/projects/${encodeURIComponent(project.projectDir)}/terminal-sessions/${
247248
encodeURIComponent(session.id)

packages/app/src/web/app-ready-controller-context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
2+
13
import type { DashboardData } from "./api.js"
24
import { createActionContext } from "./app-ready-actions.js"
35
import type { ReadyState } from "./app-ready-hooks.js"
@@ -23,7 +25,7 @@ export const createReadyActionContext = (
2325
refreshDashboard,
2426
selectedProjectId: state.selectedProjectId,
2527
selectedProjectKey: selectedProjectSummary?.projectKey ?? null,
26-
selectedProjectName: selectedProjectSummary?.displayName ?? null,
28+
selectedProjectName: selectedProjectSummary === undefined ? null : projectTerminalLabel(selectedProjectSummary),
2729
setActionPrompt: state.setActionPrompt,
2830
setActiveScreen: state.setActiveScreen,
2931
setAuthSnapshot: state.setAuthSnapshot,

packages/app/src/web/app-ready-ssh-link-terminal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
2+
13
import type { BrowserActionContext } from "./actions-shared.js"
24
import type { TerminalSession } from "./api-types.js"
35
import type { DashboardProject } from "./app-ready-ssh-link-core.js"
@@ -135,7 +137,7 @@ const buildProjectTerminalSession = (
135137
buildProjectActiveTerminalSession({
136138
onExit: args.actionContext.reloadDashboard,
137139
onReady: args.actionContext.reloadDashboard,
138-
projectDisplayName: project.displayName,
140+
projectDisplayName: projectTerminalLabel(project),
139141
projectId: project.id,
140142
projectKey: project.projectKey,
141143
session

packages/app/tests/docker-git/open-project-ssh.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => {
6969
})
7070
const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item))
7171
expect(events).toEqual([
72-
"progress:Starting project before SSH: org/repo",
72+
"progress:Starting project before SSH: org/repo | source https://github.com/org/repo.git | container dg-repo",
7373
"up:/controller/org/repo/issue-9",
74-
"progress:Opening SSH terminal: org/repo",
74+
"progress:Opening SSH terminal: org/repo | source https://github.com/org/repo.git | container dg-repo",
7575
"open:ssh -p 2299 dev@127.0.0.1"
7676
])
7777
}))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./image-paste.js"
22
export * from "./output-buffer.js"
3+
export * from "./project-terminal-label.js"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
export type ProjectTerminalLabelInput = {
2+
readonly containerName?: string | undefined
3+
readonly displayName: string
4+
readonly repoRef: string
5+
readonly repoUrl: string
6+
}
7+
8+
const issueRefPattern = /^issue-(\d+)$/u
9+
const githubPullRefPattern = /^refs\/pull\/(\d+)\/head$/u
10+
const gitlabMergeRequestRefPattern = /^refs\/merge-requests\/(\d+)\/head$/u
11+
12+
const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value
13+
14+
const readPathPart = (value: string | undefined): string | null => {
15+
const trimmed = value?.trim() ?? ""
16+
return trimmed.length > 0 ? trimmed : null
17+
}
18+
19+
const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray<string> | null => {
20+
const trimmed = repoUrl.trim()
21+
const httpsPrefix = "https://github.com/"
22+
const sshUrlPrefix = "ssh://git@github.com/"
23+
const sshScpPrefix = "git@github.com:"
24+
if (trimmed.startsWith(httpsPrefix)) {
25+
return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0)
26+
}
27+
if (trimmed.startsWith(sshUrlPrefix)) {
28+
return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0)
29+
}
30+
if (trimmed.startsWith(sshScpPrefix)) {
31+
return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0)
32+
}
33+
return null
34+
}
35+
36+
const githubRepositoryPath = (repoUrl: string): string | null => {
37+
const parts = splitGitHubRemotePath(repoUrl)
38+
const owner = readPathPart(parts?.[0])
39+
const repoRaw = readPathPart(parts?.[1])
40+
if (owner === null || repoRaw === null) {
41+
return null
42+
}
43+
return `${owner}/${stripGitSuffix(repoRaw)}`
44+
}
45+
46+
const sourceUrlForContext = (repoUrl: string, path: string): string | null => {
47+
const repoPath = githubRepositoryPath(repoUrl)
48+
return repoPath === null ? null : `https://github.com/${repoPath}/${path}`
49+
}
50+
51+
const renderIssueContext = (repoUrl: string, issueId: string): string => {
52+
const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`)
53+
return issueUrl === null ? `issue #${issueId}` : `issue #${issueId} (${issueUrl})`
54+
}
55+
56+
const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => {
57+
const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`)
58+
return pullRequestUrl === null ? `PR #${pullRequestId}` : `PR #${pullRequestId} (${pullRequestUrl})`
59+
}
60+
61+
const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}`
62+
63+
const renderSourceContext = (repoUrl: string, repoRef: string): string => {
64+
const trimmedRef = repoRef.trim()
65+
return trimmedRef.length === 0 || trimmedRef === "main"
66+
? `source ${repoUrl.trim()}`
67+
: `source ${repoUrl.trim()} (${trimmedRef})`
68+
}
69+
70+
const renderWorkspaceContext = (
71+
repoUrl: string,
72+
repoRef: string
73+
): string => {
74+
const issueMatch = issueRefPattern.exec(repoRef)
75+
if (issueMatch !== null) {
76+
const issueId = issueMatch[1]
77+
return issueId === undefined ? renderSourceContext(repoUrl, repoRef) : renderIssueContext(repoUrl, issueId)
78+
}
79+
const pullMatch = githubPullRefPattern.exec(repoRef)
80+
if (pullMatch !== null) {
81+
const pullRequestId = pullMatch[1]
82+
return pullRequestId === undefined
83+
? renderSourceContext(repoUrl, repoRef)
84+
: renderPullRequestContext(repoUrl, pullRequestId)
85+
}
86+
const mergeRequestMatch = gitlabMergeRequestRefPattern.exec(repoRef)
87+
if (mergeRequestMatch !== null) {
88+
const mergeRequestId = mergeRequestMatch[1]
89+
return mergeRequestId === undefined
90+
? renderSourceContext(repoUrl, repoRef)
91+
: renderMergeRequestContext(mergeRequestId)
92+
}
93+
return renderSourceContext(repoUrl, repoRef)
94+
}
95+
96+
const appendNonEmpty = (parts: ReadonlyArray<string>, value: string): ReadonlyArray<string> => {
97+
const trimmed = value.trim()
98+
return trimmed.length === 0 ? parts : [...parts, trimmed]
99+
}
100+
101+
/**
102+
* Builds the terminal-facing project label with source workspace context.
103+
*
104+
* @param project - Project identity returned by the docker-git API.
105+
* @returns A deterministic label for SSH terminal headers and ready messages.
106+
*
107+
* @pure true
108+
* @effect none
109+
* @invariant issue refs include an issue marker; PR refs include a PR marker; labels never omit displayName.
110+
* @precondition project.displayName identifies the repository or fallback project label.
111+
* @postcondition result contains displayName, workspace source context, and non-empty containerName when present.
112+
* @complexity O(n) where n = |repoUrl| + |repoRef|
113+
* @throws Never
114+
*/
115+
// CHANGE: surface clone-source context in SSH terminal labels
116+
// WHY: terminal headers must identify issue/PR source and container instead of only the repo path
117+
// QUOTE(ТЗ): "надо писать какой Issues какой PR вообещ что за конетейнер"
118+
// REF: issue-370
119+
// SOURCE: n/a
120+
// FORMAT THEOREM: forall p: label(p) contains displayName(p) and context(repoUrl(p), repoRef(p))
121+
// PURITY: CORE
122+
// EFFECT: none
123+
// INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty
124+
// COMPLEXITY: O(n)
125+
export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => {
126+
const displayName = project.displayName.trim()
127+
const baseName = displayName.length === 0 ? project.repoUrl.trim() : displayName
128+
const withContext = appendNonEmpty([baseName], renderWorkspaceContext(project.repoUrl, project.repoRef))
129+
const containerName = project.containerName?.trim() ?? ""
130+
const withContainer = containerName.length === 0
131+
? withContext
132+
: appendNonEmpty(withContext, `container ${containerName}`)
133+
return withContainer.join(" | ")
134+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { projectTerminalLabel } from "../../src/core/project-terminal-label.js"
4+
5+
describe("projectTerminalLabel", () => {
6+
it("renders GitHub issue source context and container identity", () => {
7+
expect(projectTerminalLabel({
8+
containerName: "dg-repo-issue-7",
9+
displayName: "org/repo",
10+
repoRef: "issue-7",
11+
repoUrl: "https://github.com/org/repo.git"
12+
})).toBe("org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7")
13+
})
14+
15+
it("renders GitHub pull request source context from pull refs", () => {
16+
expect(projectTerminalLabel({
17+
containerName: "dg-repo-pr-42",
18+
displayName: "org/repo",
19+
repoRef: "refs/pull/42/head",
20+
repoUrl: "git@github.com:org/repo.git"
21+
})).toBe("org/repo | PR #42 (https://github.com/org/repo/pull/42) | container dg-repo-pr-42")
22+
})
23+
24+
it("renders repository source context for ordinary refs", () => {
25+
expect(projectTerminalLabel({
26+
displayName: "org/repo",
27+
repoRef: "feature-x",
28+
repoUrl: "https://github.com/org/repo.git"
29+
})).toBe("org/repo | source https://github.com/org/repo.git (feature-x)")
30+
})
31+
})

scripts/e2e/clone-auto-open-ssh.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ fi
246246
grep -Fq -- "Project created: octocat/hello-world" "$CLONE_LOG" \
247247
|| fail "expected clone log to confirm project creation"
248248

249-
grep -Fq -- "SSH terminal: octocat/hello-world" "$CLONE_LOG" \
250-
|| fail "expected clone log to show SSH auto-open header"
249+
grep -Fq -- "SSH terminal: octocat/hello-world | issue #1 (https://github.com/octocat/Hello-World/issues/1) | container $CONTAINER_NAME" "$CLONE_LOG" \
250+
|| fail "expected clone log to show SSH auto-open header with issue URL and container"
251251

252252
[[ -f "$SSH_INVOCATION_LOG" ]] || fail "expected ssh wrapper to be invoked"
253253
grep -Fq -- "<-tt>" "$SSH_INVOCATION_LOG" || fail "expected ssh to request a tty"

0 commit comments

Comments
 (0)