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
1 change: 1 addition & 0 deletions packages/cli/src/ai-context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ai-context/references/initialize.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/commands/import/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1310,12 +1310,14 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co
}
}

async #initializeProject (config: ChecklyConfig): Promise<void> {
async #initializeProject (config: ChecklyConfig, configDirectory: string): Promise<void> {
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')

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand All @@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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/<owner>/<repo>\',')
expect(content).not.toContain('{{repoUrl}}')
})

it('sanitizes logicalId by replacing non-alphanumeric chars with hyphens', () => {
mockReadFileSync.mockImplementation(path => {
const p = path.toString()
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/helpers/onboarding/boilerplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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/<owner>/<repo>'`
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 {
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/services/__tests__/util.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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')
})
})
})
Loading
Loading