diff --git a/src/lib/config.ts b/src/lib/config.ts index ebf9391..255a08d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,7 +2,7 @@ import { deepmerge, dotenv, parse, path, ulid, ValidationError, z } from '../dep import { CliOptions, RunrealConfig } from '../lib/types.ts' import { execSync } from '../lib/utils.ts' import { ConfigSchema, InternalSchema } from '../lib/schema.ts' -import { Source } from './source.ts' +import { Git, Perforce, Source } from './source.ts' import { renderConfig } from './template.ts' const env = (key: string) => Deno.env.get(key) || '' @@ -16,15 +16,11 @@ export class Config { project: { name: '', path: '', + buildPath: '', repoType: 'git', }, build: { - path: '', id: env('RUNREAL_BUILD_ID') || '', - branch: '', - branchSafe: '', - commit: '', - commitShort: '', }, buildkite: { branch: env('BUILDKITE_BRANCH') || '', @@ -34,7 +30,17 @@ export class Config { buildPipelineSlug: env('BUILDKITE_PIPELINE_SLUG') || '', }, metadata: { - test: env('RUNREAL_BUILD_ID') || '', + safeRef: '', + git: { + branch: '', + branchSafe: '', + commit: '', + commitShort: '', + }, + perforce: { + changelist: '', + stream: '', + }, }, workflows: [], } @@ -43,7 +49,7 @@ export class Config { 'branch': 'engine.branch', 'cachePath': 'engine.cachePath', 'projectPath': 'project.path', - 'buildPath': 'build.path', + 'buildPath': 'project.buildPath', 'buildId': 'build.id', 'gitDependenciesCachePath': 'git.dependenciesCachePath', 'gitMirrors': 'git.mirrors', @@ -82,17 +88,6 @@ export class Config { return this.config } - determineBuildId() { - const build = this.config.build - if (!build) return ulid() - if (!this.config.project?.path) return ulid() - if (!this.config.project?.repoType) return ulid() - const source = Source(this.config.project?.path, this.config.project?.repoType) - const safeRef = source.safeRef() - if (!safeRef) return ulid() - return safeRef - } - private async searchForConfigFile(): Promise { const cwd = Deno.cwd() const configPath = path.join(cwd, 'runreal.config.json') @@ -143,8 +138,8 @@ export class Config { config.project.path = path.resolve(config.project.path) } - if (config.build && config.build.path) { - config.build.path = path.resolve(config.build.path) + if (config.project && config.project.buildPath) { + config.project.buildPath = path.resolve(config.project.buildPath) } if (config.git && config.git.dependenciesCachePath) { @@ -158,31 +153,91 @@ export class Config { return config } - private populateBuild(): RunrealConfig['build'] | null { + private getBuildMetadata(): RunrealConfig['metadata'] | null { const cwd = this.config.project?.path if (!cwd) return null + if (this.config.project?.repoType === 'git') { + const { safeRef, git } = this.getGitBuildMetadata(cwd) + return { + safeRef, + git, + } + } else if (this.config.project?.repoType === 'perforce') { + const { safeRef, perforce } = this.getPerforceBuildMetadata(cwd) + return { + safeRef, + perforce, + } + } + return null + } + + private getGitBuildMetadata(projectPath: string): RunrealConfig['metadata'] { + const cwd = projectPath try { - let branch: string - // On Buildkite, use the BUILDKITE_BRANCH env var as we may be in a detached HEAD state - if (Deno.env.get('BUILDKITE_BRANCH')) { - branch = this.config.buildkite?.branch || '' - } else { - branch = execSync('git', ['branch', '--show-current'], { cwd, quiet: false }).output.trim() + const source = new Git(cwd) + const branch = source.branch() + const branchSafe = source.branchSafe() + const commit = source.commit() + const commitShort = source.commitShort() + const safeRef = source.safeRef() + return { + safeRef, + git: { + branch, + branchSafe, + commit, + commitShort, + }, + } + } catch (e) { + return { + safeRef: '', + git: { + branch: '', + branchSafe: '', + commit: '', + commitShort: '', + }, } - const branchSafe = branch.replace(/[^a-z0-9]/gi, '-') - const commit = execSync('git', ['rev-parse', 'HEAD'], { cwd, quiet: false }).output.trim() - const commitShort = execSync('git', ['rev-parse', '--short', 'HEAD'], { quiet: false }).output.trim() + } + } + private getPerforceBuildMetadata(projectPath: string): RunrealConfig['metadata'] { + const cwd = projectPath + try { + const source = new Perforce(cwd) + const changelist = source.changelist() + const stream = source.stream() + const safeRef = source.safeRef() return { - ...this.config.build, - path: this.config.build?.path || '', - branch, - branchSafe, - commit, - commitShort, + safeRef, + perforce: { + changelist, + stream, + }, } } catch (e) { - return null + return { + safeRef: '', + perforce: { + changelist: '', + stream: '', + }, + } + } + } + + getBuildId() { + if (this.config.build?.id) return this.config.build.id + if (!this.config.project?.path) return ulid() + if (!this.config.project?.repoType) return ulid() + try { + const source = Source(this.config.project.path, this.config.project.repoType) + const safeRef = source.safeRef() + return safeRef + } catch (e) { + return ulid() } } @@ -192,10 +247,16 @@ export class Config { const Merged = ConfigSchema.and(InternalSchema) this.config = Merged.parse(this.config) - const bd = this.populateBuild() - if (bd) this.config.build = bd - if (!this.config.build?.id) { - this.config.build!.id = this.determineBuildId() + const metadata = this.getBuildMetadata() + this.config.metadata = { + ...this.config.metadata, + ...metadata, + } + + const buildId = this.getBuildId() + this.config.build = { + ...this.config.build, + id: buildId, } } catch (e) { if (e instanceof z.ZodError) { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index eeba9b3..dd94918 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -9,8 +9,18 @@ export const InternalSchema = z.object({ buildPipelineSlug: z.string().describe('Buildkite pipeline slug').optional(), }).optional(), metadata: z.object({ - test: z.string().describe('Build id '), - }).optional(), + safeRef: z.string().describe('Safe reference for file outputs or build ids').optional(), + git: z.object({ + branch: z.string().describe('Branch name'), + branchSafe: z.string().describe('Safe branch name'), + commit: z.string().describe('Commit hash'), + commitShort: z.string().describe('Short commit hash'), + }).optional(), + perforce: z.object({ + stream: z.string().describe('Stream name'), + changelist: z.string().describe('Changelist number'), + }).optional(), + }), }) export const ConfigSchema = z.object({ @@ -27,28 +37,20 @@ export const ConfigSchema = z.object({ project: z.object({ name: z.string().optional().describe('Project name'), path: z.string().describe('Path to the project folder '), + buildPath: z.string().describe('Path to the build folder '), repoType: z.string().describe('git or perforce'), }), build: z.object({ - path: z.string().describe('Path to the build folder '), id: z.string().optional().describe('Build id '), - branch: z.string().optional().describe('Branch name'), - branchSafe: z + }), + git: z.object({ + dependenciesCachePath: z .string() .optional() - .describe('Branch name safe for filenames'), - commit: z.string().optional().describe('Commit hash'), - commitShort: z.string().optional().describe('Short commit hash'), - }), - git: z - .object({ - dependenciesCachePath: z - .string() - .optional() - .describe('Path to git dependencies cache folder '), - mirrors: z.boolean().optional().describe('Use git mirrors'), - mirrorsPath: z.string().optional().describe('Path to git mirrors folder '), - }) + .describe('Path to git dependencies cache folder '), + mirrors: z.boolean().optional().describe('Use git mirrors'), + mirrorsPath: z.string().optional().describe('Path to git mirrors folder '), + }) .optional(), workflows: z.array( z.object({ diff --git a/src/lib/source.ts b/src/lib/source.ts index c87aff2..88fdbe4 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -86,14 +86,24 @@ export class Perforce extends Base { export class Git extends Base { executable: string = 'git' branch(): string { + // On Buildkite, use the BUILDKITE_BRANCH env var as we may be in a detached HEAD state + if (Deno.env.get('BUILDKITE_BRANCH')) { + return Deno.env.get('BUILDKITE_BRANCH') || '' + } return execSync(this.executable, ['branch', '--show-current'], { cwd: this.cwd, quiet: true }).output.trim() } + branchSafe(): string { + return this.branch().replace(/[^a-z0-9]/gi, '-') + } commit(): string { return execSync(this.executable, ['rev-parse', 'HEAD'], { cwd: this.cwd, quiet: true }).output.trim() } + commitShort(): string { + return execSync(this.executable, ['rev-parse', '--short', 'HEAD'], { cwd: this.cwd, quiet: true }).output.trim() + } ref(): string { - const branch = this.branch() - const commit = this.commit() + const branch = this.branchSafe() + const commit = this.commitShort() const parts: string[] = [] if (branch) { parts.push(branch) diff --git a/src/lib/template.ts b/src/lib/template.ts index 0e98d7c..0149759 100644 --- a/src/lib/template.ts +++ b/src/lib/template.ts @@ -9,10 +9,14 @@ export const getSubstitutions = (cfg: RunrealConfig): Record { const config = await Config.create() const id = ulid() - config.determineBuildId = () => id + config.getBuildId = () => id const expected = { engine: { path: '', @@ -15,15 +15,11 @@ Deno.test('Config.create should initialize with default values', async () => { project: { name: '', path: '', + buildPath: '', repoType: 'git', }, build: { id, - path: '', - branch: '', - branchSafe: '', - commit: '', - commitShort: '', }, buildkite: { branch: '', @@ -33,7 +29,17 @@ Deno.test('Config.create should initialize with default values', async () => { buildPipelineSlug: '', }, metadata: { - test: '', + safeRef: '', + git: { + branch: '', + branchSafe: '', + commit: '', + commitShort: '', + }, + perforce: { + stream: '', + changelist: '', + }, }, workflows: [], } @@ -50,12 +56,14 @@ Deno.test('Config.create should load environment variables', async () => { Deno.test('Config.get should apply CLI options', async () => { const config = await Config.create() const id = ulid() - config.determineBuildId = () => id + config.getBuildId = () => id + const enginePath = path.normalize('/path/to/engine') + const projectPath = path.normalize('/path/to/project') const cliOptions: CliOptions = { - enginePath: '/path/to/engine' as any, - projectPath: '/path/to/project' as any, + enginePath: enginePath as any, + projectPath: projectPath as any, } const result = config.get(cliOptions) - assert(result.engine.path.includes('/path/to/engine')) - assert(result.project.path.includes('/path/to/project')) + assert(result.engine.path.includes(enginePath)) + assert(result.project.path.includes(projectPath)) }) diff --git a/tests/template.test.ts b/tests/template.test.ts index 24f22b0..e4c915e 100644 --- a/tests/template.test.ts +++ b/tests/template.test.ts @@ -15,20 +15,34 @@ Deno.test('template tests', () => { Deno.test('getSubstitutions should correctly extract values from config', () => { const cfg: RunrealConfig = { - project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + project: { name: 'Project', path: '/projects/project', buildPath: '/output/path', repoType: 'git' }, engine: { path: '/engines/5.1', repoType: 'git' }, - build: { id: '1234', path: '/builds/1234', branchSafe: 'main', commitShort: 'abcd' }, + build: { id: '1234' }, buildkite: { buildNumber: '5678' }, + metadata: { + safeRef: 'safeRef', + git: { + branch: 'longbranch', + branchSafe: 'safebranch', + commit: 'commit', + commitShort: 'shortcommit', + }, + perforce: { changelist: 'cl', stream: 'stream' }, + }, } const expected = { 'engine.path': '/engines/5.1', 'project.path': '/projects/project', 'project.name': 'Project', + 'project.buildPath': '/output/path', + 'build.path': '/output/path', 'build.id': '1234', - 'build.path': '/builds/1234', - 'build.branch': 'main', - 'build.commit': 'abcd', 'buildkite.buildNumber': '5678', + 'metadata.safeRef': 'safeRef', + 'metadata.git.branch': 'safebranch', + 'metadata.git.commit': 'shortcommit', + 'metadata.perforce.changelist': 'cl', + 'metadata.perforce.stream': 'stream', } const result = getSubstitutions(cfg) assertEquals(result, expected) @@ -40,26 +54,25 @@ Deno.test('render should replace placeholders with correct values', () => { 'Build ID: ${build.id}', 'Non-existent: ${non.existent}', ] - const cfg: RunrealConfig = { - project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + const cfg: Partial = { + project: { name: 'Project', path: '/projects/project', repoType: 'git', buildPath: '/output/path' }, engine: { path: '/engines/5.1', repoType: 'git' }, - build: { id: '1234', path: '/builds/1234' }, + build: { id: '1234' }, } const expected = [ 'Project uses /engines/5.1', 'Build ID: 1234', 'Non-existent: ${non.existent}', ] - const result = render(input, cfg) + const result = render(input, cfg as RunrealConfig) assertEquals(result, expected) }) Deno.test('renderConfig should deeply replace all placeholders in config object', () => { - const cfg: RunrealConfig = { - project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + const cfg: Partial = { + project: { name: 'Project', path: '/projects/project', repoType: 'git', buildPath: '/output/path' }, engine: { path: '/engines/5.0', repoType: 'git' }, - build: { id: '1234', path: '/builds/1234' }, - metadata: { test: '${build.id}-${project.name}' }, + build: { id: '1234' }, workflows: [ { name: 'compile', @@ -76,11 +89,10 @@ Deno.test('renderConfig should deeply replace all placeholders in config object' }, ], } - const expected: RunrealConfig = { - project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + const expected: Partial = { + project: { name: 'Project', path: '/projects/project', repoType: 'git', buildPath: '/output/path' }, engine: { path: '/engines/5.0', repoType: 'git' }, - build: { id: '1234', path: '/builds/1234' }, - metadata: { test: '1234-Project' }, + build: { id: '1234' }, workflows: [ { name: 'compile', @@ -97,6 +109,6 @@ Deno.test('renderConfig should deeply replace all placeholders in config object' }, ], } - const result = renderConfig(cfg) + const result = renderConfig(cfg as RunrealConfig) assertEquals(result, expected) })