diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index 705502e87..cddb2ccf8 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -146,6 +146,7 @@ import { Frequency } from 'checkly/constructs' export default defineConfig({ projectName: "Production Monitoring Suite", logicalId: "prod-monitoring-2025", + // Must be this repo's own remote (\`git remote get-url origin\` as https). Never copy this example value. repoUrl: "https://github.com/acme/monitoring", checks: { activated: true, diff --git a/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts b/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts index eae1c4d26..646804d1e 100644 --- a/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts +++ b/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts @@ -10,6 +10,8 @@ const config = defineConfig({ * See https://www.checklyhq.com/docs/cli/constructs/ to learn more about logical IDs. */ logicalId: '{{logicalId}}', + /* The URL of this project's git repository, used to link checks to their source code */ + repoUrl: '{{repoUrl}}', /* Sets default values for Checks */ checks: { /* A default for how often your Check should run in minutes */ diff --git a/packages/cli/src/ai-context/references/initialize.md b/packages/cli/src/ai-context/references/initialize.md index d384d6471..3c0df24d7 100644 --- a/packages/cli/src/ai-context/references/initialize.md +++ b/packages/cli/src/ai-context/references/initialize.md @@ -91,6 +91,14 @@ Use the output to create a `checkly.config.ts` (or `checkly.config.js` if the us Adjust the `checkMatch` property according to previous selection. Use only locations verified as available in the previous step. +Set `repoUrl` to this repository's own remote: + +1. Run `git remote get-url origin`. +2. Convert it to an `https://` URL (`git@github.com:owner/repo.git` → `https://github.com/owner/repo`). Drop any credentials and the trailing `.git`. +3. Set the result as `repoUrl`. If there is no remote, leave `repoUrl` out. + +Never copy the `repoUrl` from the example config. + Present the generated configuration to the user and ask if it looks correct. Allow the user to make changes. Congratulate the user on completing the config. Now it's time to test the configuration and turn everything into monitoring! diff --git a/packages/cli/src/commands/import/plan.ts b/packages/cli/src/commands/import/plan.ts index cd811d611..0d9bc43e8 100644 --- a/packages/cli/src/commands/import/plan.ts +++ b/packages/cli/src/commands/import/plan.ts @@ -13,7 +13,7 @@ import { LOGICAL_ID_PATTERN } from '../../constants.js' import * as api from '../../rest/api.js' import { AuthCommand } from '../authCommand.js' import commonMessages from '../../messages/common-messages.js' -import { splitConfigFilePath } from '../../services/util.js' +import { getRepoUrlFromGit, splitConfigFilePath } from '../../services/util.js' import { ChecklyConfig, ConfigNotFoundError, loadChecklyConfig } from '../../services/checkly-config-loader.js' import { ImportPlan, ProjectNotFoundError, ImportPlanFilter, ImportPlanOptions, ResourceSync, ImportPlanFriend, FriendResourceSync, NoImportableResourcesFoundError } from '../../rest/projects.js' import { cased, Comment, docComment, Program } from '../../sourcegen/index.js' @@ -245,7 +245,7 @@ future deployments include the imported resources.` const loaded = await this.#loadConfig(configDirectory, configFilenames) const checklyConfig = loaded?.config ?? await this.#interactiveCreateConfig(configDirectory) - await this.#initializeProject(checklyConfig) + await this.#initializeProject(checklyConfig, configDirectory) const constructExports = await this.#findExportedResources( configDirectory, @@ -1310,12 +1310,14 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co } } - async #initializeProject (config: ChecklyConfig): Promise { + async #initializeProject (config: ChecklyConfig, configDirectory: string): Promise { const { logicalId, projectName, - repoUrl, } = config + // The project is new, so a URL derived from the git remote can't override + // a declared one. + const repoUrl = config.repoUrl ?? getRepoUrlFromGit(configDirectory) this.style.actionStart('Checking project status') diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts index c3c00dac5..8b5738ebd 100644 --- a/packages/cli/src/commands/trigger.ts +++ b/packages/cli/src/commands/trigger.ts @@ -164,7 +164,7 @@ export default class Trigger extends AuthCommand { const reporters = createReporters(reporterTypes, location, verbose) const testRetryStrategy = this.prepareTestRetryStrategy(retries, checklyConfig?.cli?.retries) - const repoInfo = getGitInformation() + const repoInfo = getGitInformation(checklyConfig?.repoUrl) const ciInfo = getCiInformation() const runner = new TriggerRunner( diff --git a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts index 320d4f390..b0b722f6e 100644 --- a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts +++ b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts @@ -22,6 +22,10 @@ vi.mock('../../../services/check-parser/package-files/package-manager', () => ({ }), })) +vi.mock('../../../services/util', () => ({ + getRepoUrlFromGit: vi.fn(), +})) + vi.mock('../prompts-helpers', () => ({ makeOnCancel: vi.fn(() => vi.fn()), successMessage: vi.fn((msg: string) => `OK ${msg}`), @@ -32,6 +36,7 @@ import { execSync } from 'child_process' import prompts from 'prompts' import { join } from 'path' import { detectPackageManager } from '../../../services/check-parser/package-files/package-manager.js' +import { getRepoUrlFromGit } from '../../../services/util.js' import { createConfig, copyChecks, @@ -44,9 +49,10 @@ const mockWriteFileSync = vi.mocked(writeFileSync) const mockCpSync = vi.mocked(cpSync) const mockExecSync = vi.mocked(execSync) const mockPrompts = vi.mocked(prompts) +const mockGetRepoUrlFromGit = vi.mocked(getRepoUrlFromGit) const projectDir = '/test/project' -const configTemplate = `projectName: '{{projectName}}', logicalId: '{{logicalId}}'` +const configTemplate = `projectName: '{{projectName}}', logicalId: '{{logicalId}}', repoUrl: '{{repoUrl}}',` const packageJson = JSON.stringify({ name: 'my-cool-app', devDependencies: {} }) describe('boilerplate', () => { @@ -95,6 +101,45 @@ describe('boilerplate', () => { expect(content).not.toContain('{{logicalId}}') }) + it('writes repoUrl from the git remote of the project directory', () => { + mockGetRepoUrlFromGit.mockReturnValue('https://github.com/acme/app') + + createConfig(projectDir, log) + + expect(mockGetRepoUrlFromGit).toHaveBeenCalledWith(projectDir) + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('repoUrl: \'https://github.com/acme/app\',') + expect(content).not.toContain('{{repoUrl}}') + }) + + it('writes a repoUrl with quotes and replacement patterns as an escaped string literal', () => { + mockGetRepoUrlFromGit.mockReturnValue('https://h.example/o/r\');x(\'$\'') + + createConfig(projectDir, log) + + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('repoUrl: "https://h.example/o/r\');x(\'$\'",') + }) + + it('writes a commented repoUrl placeholder when there is no git remote', () => { + mockGetRepoUrlFromGit.mockReturnValue(undefined) + + createConfig(projectDir, log) + + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('// repoUrl: \'https://github.com//\',') + expect(content).not.toContain('{{repoUrl}}') + }) + it('sanitizes logicalId by replacing non-alphanumeric chars with hyphens', () => { mockReadFileSync.mockImplementation(path => { const p = path.toString() diff --git a/packages/cli/src/helpers/onboarding/boilerplate.ts b/packages/cli/src/helpers/onboarding/boilerplate.ts index 67ae8cf5d..2e1db14f3 100644 --- a/packages/cli/src/helpers/onboarding/boilerplate.ts +++ b/packages/cli/src/helpers/onboarding/boilerplate.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url' import { detectPackageManager } from '../../services/check-parser/package-files/package-manager.js' import { makeOnCancel, successMessage } from './prompts-helpers.js' +import { getRepoUrlFromGit } from '../../services/util.js' +import { quote } from '../../sourcegen/string.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -90,9 +92,19 @@ export function createConfig ( } const projectName = getProjectName(projectDir) const logicalId = sanitizeLogicalId(projectName) + const repoUrl = getRepoUrlFromGit(projectDir) + // The template keeps the slot as valid TypeScript; without a remote the + // whole property becomes a commented placeholder. + // The URL comes from local git config, so it is quoted as a string literal + // rather than pasted into the generated source. + const repoUrlProperty = repoUrl + ? `repoUrl: ${quote(repoUrl)}` + : `// repoUrl: 'https://github.com//'` const content = template .replaceAll('{{projectName}}', projectName) .replaceAll('{{logicalId}}', logicalId) + // A function replacer keeps `$'`-style patterns in the URL literal. + .replaceAll(`repoUrl: '{{repoUrl}}'`, () => repoUrlProperty) try { writeFileSync(configPath, content) } catch { diff --git a/packages/cli/src/services/__tests__/util.spec.ts b/packages/cli/src/services/__tests__/util.spec.ts index dceae72bc..919080d6a 100644 --- a/packages/cli/src/services/__tests__/util.spec.ts +++ b/packages/cli/src/services/__tests__/util.spec.ts @@ -1,18 +1,25 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, it, expect } from 'vitest' import { getGitInformation, getGitRepoRoot, + getRepoUrlFromGit, + normalizeGitRemoteUrl, pathToPosix, isFileSync, } from '../util.js' const ENV_KEYS = [ 'CHECKLY_REPO_SHA', + 'CHECKLY_TEST_REPO_SHA', 'CHECKLY_REPO_URL', + 'CHECKLY_TEST_REPO_URL', + 'GITHUB_SERVER_URL', + 'GITHUB_REPOSITORY', 'CHECKLY_REPO_BRANCH', 'CHECKLY_GITHUB_REPORT', 'CHECKLY_GITHUB_SOURCE', @@ -197,4 +204,98 @@ describe('util', () => { })) }) }) + + describe('normalizeGitRemoteUrl()', () => { + it.each([ + ['git@github.com:acme/app.git', 'https://github.com/acme/app'], + ['ssh://git@github.com/acme/app.git', 'https://github.com/acme/app'], + ['ssh://git@gitlab.example.com:2222/group/sub/app.git', 'https://gitlab.example.com/group/sub/app'], + ['https://gitlab-ci-token:s3cr3t@gitlab.com/acme/app.git', 'https://gitlab.com/acme/app'], + ['https://x-access-token@github.com/acme/app/', 'https://github.com/acme/app'], + ['https://github.com/acme/app', 'https://github.com/acme/app'], + ['http://git.internal:8080/acme/app.git\n', 'http://git.internal:8080/acme/app'], + ])('normalizes %s', (remote, expected) => { + expect(normalizeGitRemoteUrl(remote)).toBe(expected) + }) + + it.each([ + [''], + ['/srv/git/app.git'], + ['../app'], + ['file:///srv/git/app.git'], + ['C:\\repos\\app'], + ['git@github-work:acme/app.git'], + ['ssh://git@gitalias/acme/app.git'], + ['git@ssh.dev.azure.com:v3/acme/project/app'], + ['acme@vs-ssh.visualstudio.com:v3/acme/project/app'], + ])('returns undefined for %s', remote => { + expect(normalizeGitRemoteUrl(remote)).toBeUndefined() + }) + }) + + describe('git remote detection', () => { + let tmpDir: string + + const git = (...args: string[]) => execFileSync('git', args, { cwd: tmpDir, stdio: 'ignore' }) + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'checkly-git-remote-')) + git('init', '-q') + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('reads and cleans the origin remote', () => { + git('remote', 'add', 'origin', 'https://ci-token:abc@github.com/acme/app.git') + expect(getRepoUrlFromGit(tmpDir)).toBe('https://github.com/acme/app') + }) + + it('returns undefined without an origin remote', () => { + expect(getRepoUrlFromGit(tmpDir)).toBeUndefined() + }) + + it('returns undefined outside a git repository', () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'checkly-no-git-')) + try { + expect(getRepoUrlFromGit(outside)).toBeUndefined() + } finally { + fs.rmSync(outside, { recursive: true, force: true }) + } + }) + + it('sends the repo URL without commitId for a repository with no commits', () => { + git('remote', 'add', 'origin', 'git@github.com:acme/app.git') + const info = getGitInformation(undefined, tmpDir) + expect(info).toEqual(expect.objectContaining({ repoUrl: 'https://github.com/acme/app' })) + expect(info).not.toHaveProperty('commitId') + }) + + it('returns null for a repository with no commits and no remote', () => { + expect(getGitInformation(undefined, tmpDir)).toBeNull() + }) + + it('resolves the repo URL in fallback order', () => { + git('remote', 'add', 'origin', 'git@github.com:acme/from-git.git') + const resolve = (configRepoUrl?: string) => getGitInformation(configRepoUrl, tmpDir)?.repoUrl + + expect(resolve()).toBe('https://github.com/acme/from-git') + + process.env.GITHUB_SERVER_URL = 'https://github.com' + process.env.GITHUB_REPOSITORY = 'acme/from-actions' + expect(resolve()).toBe('https://github.com/acme/from-actions') + + process.env.CHECKLY_GITHUB_REPOSITORY = 'acme/from-checkly-github' + expect(resolve()).toBe('https://github.com/acme/from-checkly-github') + + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-config') + + process.env.CHECKLY_TEST_REPO_URL = 'https://github.com/acme/from-test-env' + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-test-env') + + process.env.CHECKLY_REPO_URL = 'https://github.com/acme/from-env' + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-env') + }) + }) }) diff --git a/packages/cli/src/services/util.ts b/packages/cli/src/services/util.ts index 98147ab79..7330ada75 100644 --- a/packages/cli/src/services/util.ts +++ b/packages/cli/src/services/util.ts @@ -1,6 +1,7 @@ import * as path from 'path' import * as fs from 'fs/promises' import * as fsSync from 'fs' +import { execFileSync } from 'child_process' import gitRepoInfo from 'git-repo-info' import { parse } from 'dotenv' @@ -10,7 +11,8 @@ import JSON5 from 'json5' import { existsSync } from 'fs' export interface GitInformation { - commitId: string + /** Absent when the repository has no commits yet but a repo URL is known. */ + commitId?: string repoUrl?: string | null branchName?: string | null commitOwner?: string | null @@ -51,6 +53,105 @@ function getGitHubRepositoryUrl (): string | undefined { return `${serverUrl.replace(/\/$/, '')}/${repository}` } +/** The repository URL from GitHub Actions' built-in env vars. */ +function getGitHubActionsRepositoryUrl (): string | undefined { + const serverUrl = process.env.GITHUB_SERVER_URL + const repository = process.env.GITHUB_REPOSITORY + if (!serverUrl || !repository) { + return undefined + } + return `${serverUrl.replace(/\/$/, '')}/${repository}` +} + +/** + * Turns a git remote URL into a credential-free web URL, e.g. + * `git@github.com:acme/app.git` -> `https://github.com/acme/app`. + * Returns `undefined` for remotes that have no web URL (local paths, file://, + * SSH config aliases, SSH hosts with a different web path layout). + */ +export function normalizeGitRemoteUrl (remote: string): string | undefined { + const trimmed = remote.trim() + if (!trimmed) { + return undefined + } + + let host: string + let repoPath: string + let protocol = 'https:' + let isSshRemote = true + + // scp-like syntax: [user@]host:path. A single-letter host is a Windows drive. + const scpLike = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)$/) + if (scpLike) { + host = scpLike[1] + repoPath = scpLike[2] + } else { + let url: URL + try { + url = new URL(trimmed) + } catch { + return undefined + } + if (url.protocol === 'https:' || url.protocol === 'http:') { + // Keep the port: it is part of the web address. Credentials are dropped. + protocol = url.protocol + host = url.host + isSshRemote = false + } else if (url.protocol === 'ssh:' || url.protocol === 'git+ssh:' || url.protocol === 'git:') { + // SSH and git-daemon ports don't carry over to the web URL. + host = url.hostname + } else { + return undefined + } + repoPath = url.pathname + } + + repoPath = repoPath + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, '') + .replace(/\/+$/, '') + if (!host || !repoPath) { + return undefined + } + if (isSshRemote && !isWebHostForSshRemote(host)) { + return undefined + } + return `${protocol}//${host}/${repoPath}` +} + +// SSH hosts whose repository paths don't map onto a web URL of the same shape. +const NON_WEB_SSH_HOSTS = new Set(['ssh.dev.azure.com', 'vs-ssh.visualstudio.com']) + +/** + * Whether the host of an SSH remote (converted to https) is also a web host. + * A host without a dot is an SSH config alias (e.g. `github-work`), not a + * resolvable web address. + */ +function isWebHostForSshRemote (host: string): boolean { + const hostname = host.toLowerCase() + return hostname.includes('.') && !NON_WEB_SSH_HOSTS.has(hostname) +} + +/** + * The web URL of the `origin` remote of the git repository at `cwd`, with + * credentials stripped. `undefined` when there is no repository, no origin, + * or git is not installed. + */ +export function getRepoUrlFromGit (cwd: string = process.cwd()): string | undefined { + let remote: string + try { + remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + return normalizeGitRemoteUrl(remote) +} + function isGitHubReportingEnabled (): boolean { const value = (process.env.CHECKLY_GITHUB_REPORT ?? '').trim().toLowerCase() return value === 'true' || value === '1' @@ -122,27 +223,37 @@ export function isFileSync (path: string): boolean { } /** * @param repoUrl default repoURL the user can set in their project config. + * @param cwd directory to read git information from. */ -export function getGitInformation (repoUrl?: string): GitInformation | null { - const repositoryInfo = gitRepoInfo() - - if ( - !process.env.CHECKLY_REPO_SHA - && !process.env.CHECKLY_TEST_REPO_SHA - && !process.env.CHECKLY_GITHUB_SHA - && !repositoryInfo.sha - ) { +export function getGitInformation (repoUrl?: string, cwd: string = process.cwd()): GitInformation | null { + const repositoryInfo = gitRepoInfo(cwd) + + const commitId = process.env.CHECKLY_REPO_SHA + ?? process.env.CHECKLY_TEST_REPO_SHA + ?? process.env.CHECKLY_GITHUB_SHA + ?? repositoryInfo.sha + ?? undefined + + // Declared values first; the git remote is only a last resort. Callers must + // not copy a derived URL into project.repoUrl: the backend only lets it fill + // a project that has no URL yet. + const resolvedRepoUrl = process.env.CHECKLY_REPO_URL + ?? process.env.CHECKLY_TEST_REPO_URL + ?? repoUrl + ?? getGitHubRepositoryUrl() + ?? getGitHubActionsRepositoryUrl() + ?? getRepoUrlFromGit(cwd) + + // A repository without commits still has a URL worth sending. + if (!commitId && !resolvedRepoUrl) { return null } // safe way to remove the email address const committer = (repositoryInfo.committer?.match(/([^<]+)/) || [])[1]?.trim() const gitInformation: GitInformation = { - commitId: process.env.CHECKLY_REPO_SHA - ?? process.env.CHECKLY_TEST_REPO_SHA - ?? process.env.CHECKLY_GITHUB_SHA - ?? repositoryInfo.sha, - repoUrl: process.env.CHECKLY_REPO_URL ?? process.env.CHECKLY_TEST_REPO_URL ?? repoUrl ?? getGitHubRepositoryUrl(), + ...(commitId ? { commitId } : {}), + repoUrl: resolvedRepoUrl, branchName: process.env.CHECKLY_REPO_BRANCH ?? process.env.CHECKLY_TEST_REPO_BRANCH ?? repositoryInfo.branch, commitOwner: process.env.CHECKLY_REPO_COMMIT_OWNER ?? process.env.CHECKLY_TEST_REPO_COMMIT_OWNER ?? committer, commitMessage: process.env.CHECKLY_REPO_COMMIT_MESSAGE