UNPKG

@atomist/sdm-pack-aspect

Version:

an Atomist SDM Extension Pack for visualizing drift across an organization

686 lines (636 loc) 29.4 kB
/* * Copyright © 2019 Atomist, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { GitHubRepoRef, logger, RemoteRepoRef, RepoRef, } from "@atomist/automation-client"; import { ConcreteIdeal, FP, Ideal, isConcreteIdeal, } from "@atomist/sdm-pack-fingerprint"; import * as camelcaseKeys from "camelcase-keys"; import * as _ from "lodash"; import { Client, ClientBase, } from "pg"; import { Analyzed } from "../../../aspect/AspectRegistry"; import { IdealStore } from "../../../aspect/IdealStore"; import { ProblemUsage } from "../../../aspect/ProblemStore"; import { PlantedTree, TagUsage, } from "../../../tree/sunburst"; import { BandCasing, bandFor, } from "../../../util/bands"; import { EntropySizeBands } from "../../../util/commonBands"; import { isProjectAnalysisResult, ProjectAnalysisResult, } from "../../ProjectAnalysisResult"; import { CohortAnalysis } from "../spider/analytics"; import { SpideredRepo } from "../SpideredRepo"; import { ClientFactory, doWithClient, } from "./pgUtils"; import { combinePersistResults, emptyPersistResult, FingerprintInsertionResult, FingerprintKind, FingerprintUsage, PersistResult, ProjectAnalysisResultStore, TreeQuery, } from "./ProjectAnalysisResultStore"; import { driftTreeForAllAspects, driftTreeForSingleAspect, fingerprintsToReposTreeQuery, } from "./repoTree"; // tslint:disable:max-file-line-count export class PostgresProjectAnalysisResultStore implements ProjectAnalysisResultStore, IdealStore { public fingerprintsToReposTree(treeQuery: TreeQuery): Promise<PlantedTree> { return fingerprintsToReposTreeQuery(treeQuery, this.clientFactory); } public aspectDriftTree(workspaceId: string, percentile: number, options?: { repos?: boolean, type?: string }): Promise<PlantedTree> { return !!options && !!options.type ? driftTreeForSingleAspect(workspaceId, percentile, options, this.clientFactory) : driftTreeForAllAspects(workspaceId, percentile, this.clientFactory); } public distinctRepoCount(workspaceId: string): Promise<number> { const sql = `SELECT COUNT(1) FROM (SELECT DISTINCT url FROM repo_snapshots WHERE workspace_id ${workspaceId === "*" ? "<>" : "="} $1) as repos`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return +result.rows[0].count; }); } public virtualProjectCount(workspaceId: string): Promise<number> { const sql = `SELECT COUNT(1) FROM (SELECT DISTINCT repo_snapshot_id, path FROM repo_snapshots, repo_fingerprints WHERE workspace_id ${workspaceId === "*" ? "<>" : "="} $1 AND repo_fingerprints.repo_snapshot_id = repo_snapshots.id) as virtual_repos`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return +result.rows[0].count; }); } public latestTimestamp(workspaceId: string): Promise<Date> { const sql = `SELECT timestamp FROM repo_snapshots WHERE workspace_id ${workspaceId === "*" ? "<>" : "="} $1 ORDER BY timestamp DESC LIMIT 1`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return result.rows[0].timestamp; }); } public loadInWorkspace(workspaceId: string, deep: boolean): Promise<ProjectAnalysisResult[]> { return this.loadInWorkspaceInternal(workspaceId || "*", deep); } /** * Load repo * @param {string} workspaceId workspace id * @param {boolean} deep whether to load fingerprints also * @param {string} additionalWhereClause does not use aliases, but original table names * @param {any[]} additionalParameters additional parameters required by additional where clause * @return {Promise<ProjectAnalysisResult[]>} */ private async loadInWorkspaceInternal(workspaceId: string, deep: boolean, additionalWhereClause: string = "true", additionalParameters: any[] = []): Promise<ProjectAnalysisResult[]> { const reposOnly = `SELECT id, owner, name, url, commit_sha, timestamp, workspace_id FROM repo_snapshots WHERE workspace_id ${workspaceId !== "*" ? "=" : "<>"} $1 AND ${additionalWhereClause}`; const reposAndFingerprints = `SELECT repo_snapshots.id, repo_snapshots.owner, repo_snapshots.name, repo_snapshots.url, repo_snapshots.commit_sha, repo_snapshots.timestamp, repo_snapshots.workspace_id, json_agg(json_build_object('path', path, 'id', fingerprint_id)) as fingerprint_refs FROM repo_snapshots LEFT JOIN repo_fingerprints ON repo_snapshots.id = repo_fingerprints.repo_snapshot_id WHERE workspace_id ${workspaceId !== "*" ? "=" : "<>"} $1 AND ${additionalWhereClause} GROUP BY repo_snapshots.id`; const queryForRepoRows = doWithClient(deep ? reposAndFingerprints : reposOnly, this.clientFactory, async client => { // Load all fingerprints in workspace so we can look up const repoSnapshotRows = await client.query(deep ? reposAndFingerprints : reposOnly, [workspaceId, ...additionalParameters]); return repoSnapshotRows.rows.map(whyDoesPostgresPutANewlineOnSomeFields).map(row => { const repoRef = rowToRepoRef(row); return { id: row.id, owner: row.owner, name: row.name, url: row.url, commitSha: row.commit_sha, timestamp: row.timestamp, workspaceId: row.workingDescription, repoRef, fingerprintRefs: row.fingerprint_refs, analysis: undefined, }; }); }, []); if (deep) { // We do this join manually instead of returning JSON because of the extent of the duplication // and the resulting memory usage. // We parallelize the 2 needed queries to reduce latency const getFingerprints = this.fingerprintsInWorkspaceRecord(workspaceId); const [repoRows, fingerprints] = await Promise.all([queryForRepoRows, getFingerprints]); for (const repo of repoRows) { repo.analysis = { id: repo.repoRef, fingerprints: repo.fingerprintRefs.map(fref => { return { ...fingerprints[fref.id], path: fref.path, }; }), }; } return repoRows; } return queryForRepoRows; } public async loadById(id: string, deep: boolean): Promise<ProjectAnalysisResult | undefined> { const hits = await this.loadInWorkspaceInternal("*", deep, "repo_snapshots.id = $2", [id]); return hits.length === 1 ? hits[0] : undefined; } public async loadByRepoRef(repo: RepoRef, deep: boolean): Promise<ProjectAnalysisResult | undefined> { const hits = await this.loadInWorkspaceInternal("*", deep, "repo_snapshots.owner = $2 AND repo_snapshots.name = $3 AND repo_snapshots.commit_sha = $4", [repo.owner, repo.repo, repo.sha]); return hits.length === 1 ? hits[0] : undefined; } public async persist(repos: ProjectAnalysisResult | AsyncIterable<ProjectAnalysisResult> | ProjectAnalysisResult[]): Promise<PersistResult> { return this.persistAnalysisResults(isProjectAnalysisResult(repos) ? [repos] : repos); } public async distinctFingerprintKinds(workspaceId: string): Promise<FingerprintKind[]> { const sql = `SELECT DISTINCT f.name, feature_name as type FROM repo_fingerprints rf, repo_snapshots rs, fingerprints f WHERE rf.repo_snapshot_id = rs.id AND rf.fingerprint_id = f.id AND rs.workspace_id ${workspaceId === "*" ? "<>" : "="} $1`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return result.rows; }, []); } public async distinctRepoFingerprintKinds(workspaceId: string): Promise<Array<{ owner: string, repo: string, fingerprints: FingerprintKind[] }>> { const sql = `SELECT DISTINCT rs.owner, rs.name as repo, f.name, feature_name as type FROM repo_fingerprints rf, repo_snapshots rs, fingerprints f WHERE rf.repo_snapshot_id = rs.id AND rf.fingerprint_id = f.id AND rs.workspace_id ${workspaceId === "*" ? "<>" : "="} $1`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return _.map(_.groupBy(result.rows, r => `${r.owner}/${r.repo}`), (v, k) => { return { owner: k.split("/")[0], repo: k.split("/")[1], fingerprints: v, }; }); }, []); } public tags(workspaceId: string): Promise<TagUsage[]> { const sql = `SELECT fp.name as name, fp.data ->> 'description' as description, fp.feature_name as parent, count(fp.name) FROM repo_snapshots rs, repo_fingerprints j, fingerprints fp WHERE j.repo_snapshot_id = rs.id and j.fingerprint_id = fp.id AND rs.workspace_id ${workspaceId === "*" ? "<>" : "="} $1 AND fp.data ->> 'reason' IS NOT NULL GROUP BY fp.name, parent, description`; return doWithClient(sql, this.clientFactory, async client => { const result = await client.query(sql, [workspaceId]); return result.rows; }, []); } public fingerprintUsageForType(workspaceId: string, type?: string): Promise<FingerprintUsage[]> { return fingerprintUsageForType(this.clientFactory, workspaceId, type); } public async storeIdeal(workspaceId: string, ideal: Ideal): Promise<void> { if (isConcreteIdeal(ideal)) { await doWithClient("Store ideal", this.clientFactory, async client => { // Clear out any existing ideal await client.query("DELETE FROM ideal_fingerprints WHERE workspace_id = $1 AND fingerprint_id IN " + "(SELECT id from fingerprints where feature_name = $2 AND name = $3)", [workspaceId, ideal.ideal.type, ideal.ideal.name]); const fid = await this.ensureFingerprintStored(ideal.ideal, client); await client.query(`INSERT INTO ideal_fingerprints (workspace_id, fingerprint_id, authority) values ($1, $2, 'local-user')`, [ workspaceId, fid]); }); } else { throw new Error("Elimination ideals not yet supported"); } } public async setIdeal(workspaceId: string, fingerprintId: string): Promise<void> { const ideal = await this.loadFingerprintById(fingerprintId); if (!ideal) { throw new Error(`Fingerprint with id=${fingerprintId} not found and cannot be used as an ideal`); } const ci: ConcreteIdeal = { reason: "Local database", ideal, }; await this.storeIdeal(workspaceId, ci); } public async loadIdeals(workspaceId: string): Promise<Ideal[]> { const sql = `SELECT id, name, feature_name as type, sha, data FROM ideal_fingerprints, fingerprints WHERE workspace_id = $1 AND ideal_fingerprints.fingerprint_id = fingerprints.id`; return doWithClient(sql, this.clientFactory, async client => { const rows = await client.query(sql, [workspaceId]); if (!rows.rows) { return []; } return rows.rows.map(idealRowToIdeal); }, []); } public async noteProblem(workspaceId: string, fingerprintId: string): Promise<void> { const fingerprint = await this.loadFingerprintById(fingerprintId); if (!fingerprint) { throw new Error(`Fingerprint with id=${fingerprintId} not found and cannot be noted as problem`); } await this.storeProblemFingerprint(workspaceId, { fingerprint, severity: "warn", authority: "local-user" }); } public async storeProblemFingerprint(workspaceId: string, fp: ProblemUsage): Promise<void> { const sql = `INSERT INTO problem_fingerprints (workspace_id, fingerprint_id, severity, authority, date_added) values ($1, $2, $3, $4, current_timestamp)`; await doWithClient(sql, this.clientFactory, async client => { // Clear out any existing ideal const fid = await this.ensureFingerprintStored(fp.fingerprint, client); await client.query(sql, [ workspaceId, fid, fp.severity, fp.authority]); }); } public async loadProblems(workspaceId: string): Promise<ProblemUsage[]> { const sql = `SELECT id, name, feature_name as type, sha, data, authority, severity, description, url FROM problem_fingerprints, fingerprints WHERE workspace_id = $1 AND problem_fingerprints.fingerprint_id = fingerprints.id`; return doWithClient(sql, this.clientFactory, async client => { const rows = await client.query(sql, [workspaceId]); if (!rows.rows) { return []; } return rows.rows.map(problemRowToProblem); }, []); } public async loadIdeal(workspaceId: string, type: string, name: string): Promise<Ideal> { const sql = `SELECT id, name, feature_name as type, sha, data, display_name, display_value FROM ideal_fingerprints, fingerprints WHERE workspace_id = $1 AND ideal_fingerprints.fingerprint_id = fingerprints.id AND feature_name = $2 AND name = $3`; const rawRow = await doWithClient(sql, this.clientFactory, async client => { const rows = await client.query(sql, [workspaceId, type, name]); return rows.rows.length === 1 ? rows.rows[0] : undefined; }); if (!rawRow) { return undefined; } return camelcaseKeys(idealRowToIdeal(rawRow), { deep: true }) as any; } public async loadFingerprintById(id: string): Promise<FP | undefined> { const sql = `SELECT id, name, feature_name as type, sha, data FROM fingerprints WHERE id = $1`; return doWithClient(sql, this.clientFactory, async client => { const rows = await client.query(sql, [id]); return rows.rows.length === 1 ? rows.rows[0] : undefined; }); } /** * Key is persistent fingerprint id */ private async fingerprintsInWorkspaceRecord(workspaceId: string, type?: string, name?: string): Promise<Record<string, FP & { id: string }>> { const fingerprintsArray = await this.fingerprintsInWorkspace(workspaceId, true, type, name); const fingerprints: Record<string, FP & { id: string }> = {}; fingerprintsArray.forEach(fp => fingerprints[fp.id] = fp); return fingerprints; } public async fingerprintsInWorkspace(workspaceId: string, distinct: boolean, type?: string, name?: string): Promise<Array<FP & { id: string }>> { return fingerprintsInWorkspace(this.clientFactory, workspaceId, distinct, type, name); } public async fingerprintsForProject(snapshotId: string): Promise<Array<FP & { timestamp: Date, commitSha: string }>> { return fingerprintsForProject(this.clientFactory, snapshotId); } public async averageFingerprintCount(workspaceId?: string): Promise<number> { const sql = `SELECT avg(count) as average_fingerprints from (SELECT repo_snapshots.id, count(feature_name) from repo_snapshots, (select distinct feature_name, repo_snapshot_id, repo_fingerprints.path FROM repo_fingerprints, fingerprints WHERE repo_fingerprints.fingerprint_id = fingerprints.id) AS aspects WHERE workspace_id ${workspaceId === "*" ? "<>" : "="} $1 AND repo_snapshot_id = repo_snapshots.id GROUP by repo_snapshots.id) stats;`; return doWithClient(sql, this.clientFactory, async client => { const rows = await client.query(sql, [workspaceId || "*"]); return rows.rows.length === 1 ? rows.rows[0].average_fingerprints : -1; }, () => -1); } public async persistAnalytics(data: Array<{ workspaceId: string, kind: FingerprintKind, cohortAnalysis: CohortAnalysis }>): Promise<boolean> { return doWithClient("Persist analytics", this.clientFactory, async client => { for (const { kind, workspaceId, cohortAnalysis } of data) { const sql = `INSERT INTO fingerprint_analytics (feature_name, name, workspace_id, entropy, variants, count) values ($1, $2, $3, $4, $5, $6) ON CONFLICT ON CONSTRAINT fingerprint_analytics_pkey DO UPDATE SET entropy = $4, variants = $5, count = $6`; await client.query(sql, [kind.type, kind.name, workspaceId, cohortAnalysis.entropy, cohortAnalysis.variants, cohortAnalysis.count]); } return true; }); } private async persistAnalysisResults( analysisResultIterator: AsyncIterable<ProjectAnalysisResult> | ProjectAnalysisResult[]): Promise<PersistResult> { return doWithClient("Persist analysis results", this.clientFactory, async client => { const persistResults: PersistResult[] = []; for await (const analysisResult of analysisResultIterator) { persistResults.push(await this.persistOne(client, analysisResult)); } return persistResults.reduce(combinePersistResults, emptyPersistResult); }, emptyPersistResult); } private async persistOne(client: ClientBase, analysisResult: ProjectAnalysisResult): Promise<PersistResult> { const repoRef = analysisResult.repoRef; if (!repoRef) { return { attemptedCount: 1, succeeded: [], failed: [{ repoUrl: "missing repoRef", whileTryingTo: "build object to persist", message: "No RepoRef", }], failedFingerprints: [], }; } if (!repoRef.url || !repoRef.sha) { return { attemptedCount: 1, succeeded: [], failed: [{ repoUrl: "missing repoUrl. Repo is named " + repoRef.repo, whileTryingTo: "build object to persist", message: `Incomplete RepoRef ${JSON.stringify(repoRef)}`, }], failedFingerprints: [], }; } try { // Whack any snapshot we already hold for this repository await deleteOldSnapshotForRepository(repoRef, client); // Use this as unique database id const id = snapshotIdFor(repoRef); const shaToUse = repoRef.sha; const repoSnapshotsInsertSql = `INSERT INTO repo_snapshots (id, workspace_id, provider_id, owner, name, url, commit_sha, query, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, current_timestamp)`; logger.debug("Executing SQL:\n%s", repoSnapshotsInsertSql); await client.query(repoSnapshotsInsertSql, [id, analysisResult.workspaceId, "github", repoRef.owner, repoRef.repo, repoRef.url, shaToUse, (analysisResult as SpideredRepo).query, ]); const fingerprintPersistResults = await this.persistFingerprints(analysisResult.analysis, id, client); fingerprintPersistResults.failures.forEach(f => { logger.error(`Could not persist fingerprint. Error: ${f.error.message} Repo: ${repoRef.url} Fingerprint: ${JSON.stringify(f.failedFingerprint, undefined, 2)}`); }); return { succeeded: [id], attemptedCount: 1, failed: [], failedFingerprints: fingerprintPersistResults.failures, }; } catch (err) { return { attemptedCount: 1, succeeded: [], failed: [{ repoUrl: repoRef.url, whileTryingTo: "persist in DB", message: err.message, }], failedFingerprints: [], }; } } public async persistAdditionalFingerprints(analyzed: Analyzed): Promise<FingerprintInsertionResult> { return doWithClient(`Persist additional fingerprints for project at ${analyzed.id.url}`, this.clientFactory, async client => { return this.persistFingerprints(analyzed, snapshotIdFor(analyzed.id), client); }, { insertedCount: 0, failures: analyzed.fingerprints .map(failedFingerprint => ({ failedFingerprint, error: undefined })), }); } // Persist the fingerprints for this analysis private async persistFingerprints(pa: Analyzed, id: string, client: ClientBase): Promise<FingerprintInsertionResult> { let insertedCount = 0; const failures: Array<{ failedFingerprint: FP; error: Error }> = []; for (const fp of pa.fingerprints) { const aspectName = fp.type || "unknown"; const fingerprintId = aspectName + "_" + fp.name + "_" + fp.sha; // console.log("Persist fingerprint " + JSON.stringify(fp) + " for id " + id); // Create fp record if it doesn't exist try { await this.ensureFingerprintStored(fp, client); const insertRepoFingerprintSql = `INSERT INTO repo_fingerprints (repo_snapshot_id, fingerprint_id, path) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`; await client.query(insertRepoFingerprintSql, [id, fingerprintId, fp.path || ""]); insertedCount++; } catch (error) { failures.push({ failedFingerprint: fp, error }); } } return { insertedCount, failures, }; } /** * Persist the given fingerprint if it's not already known * @param {FP} fp * @param {Client} client * @return {Promise<void>} */ private async ensureFingerprintStored(fp: FP, client: ClientBase): Promise<string> { const aspectName = fp.type || "unknown"; const fingerprintId = aspectName + "_" + fp.name + "_" + fp.sha; // console.log("Persist fingerprint " + JSON.stringify(fp) + " for id " + id); // Create fp record if it doesn't exist const insertFingerprintSql = `INSERT INTO fingerprints (id, name, feature_name, sha, data, display_name, display_value) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING`; logger.debug("Persisting fingerprint %j SQL\n%s", fp, insertFingerprintSql); await client.query(insertFingerprintSql, [fingerprintId, fp.name, aspectName, fp.sha, JSON.stringify(fp.data), fp.displayName, fp.displayValue]); return fingerprintId; } constructor(public readonly clientFactory: ClientFactory) { } } function idealRowToIdeal(rawRow: any): Ideal { if (!!rawRow.data) { const result: ConcreteIdeal = { ideal: rawRow, reason: `Local database row ${rawRow.id}`, }; return result; } throw new Error("Elimination ideals not yet supported"); } function problemRowToProblem(rawRow: any): ProblemUsage { return { fingerprint: { name: rawRow.name, type: rawRow.feature_name, data: rawRow.data, sha: rawRow.sha, }, authority: rawRow.authority, severity: rawRow.severity, description: rawRow.description, url: rawRow.url, }; } /** * Raw fingerprints in the workspace * @return {Promise<FP[]>} */ async function fingerprintsInWorkspace(clientFactory: ClientFactory, workspaceId: string, distinct: boolean, type?: string, name?: string): Promise<Array<FP & { id: string }>> { const sql = `SELECT ${distinct ? "DISTINCT" : ""} f.name, f.id, f.feature_name as type, f.sha, f.data FROM repo_snapshots rs RIGHT JOIN repo_fingerprints rf ON rf.repo_snapshot_id = rs.id INNER JOIN fingerprints f ON rf.fingerprint_id = f.id WHERE rs.workspace_id ${workspaceId === "*" ? "<>" : "="} $1 AND ${type ? "f.feature_name = $2" : "true"} AND ${name ? "f.name = $3" : "true"}`; return doWithClient(sql, clientFactory, async client => { const params = [workspaceId]; if (!!type) { params.push(type); } if (!!name) { params.push(name); } const rows = await client.query(sql, params); const fps = rows.rows; logger.debug("%d fingerprints in workspace '%s'", fps.length, workspaceId); return fps; }, []); } async function fingerprintsForProject(clientFactory: ClientFactory, snapshotId: string): Promise<Array<FP & { timestamp: Date, commitSha: string }>> { const sql = `SELECT f.name as fingerprintName, f.feature_name, f.sha, f.data, rf.path, rs.timestamp, rs.commit_sha FROM repo_fingerprints rf, repo_snapshots rs, fingerprints f WHERE rs.id = $1 AND rf.repo_snapshot_id = rs.id AND rf.fingerprint_id = f.id ORDER BY feature_name, fingerprintName ASC`; return doWithClient(sql, clientFactory, async client => { const rows = await client.query(sql, [snapshotId]); return rows.rows.map(row => { return { name: row.fingerprintname, type: row.feature_name, sha: row.sha, data: row.data, path: row.path, timestamp: row.timestamp, commitSha: row.commit_sha, }; }); }, []); } async function fingerprintUsageForType(clientFactory: ClientFactory, workspaceId: string, type?: string): Promise<FingerprintUsage[]> { const sql = `SELECT name, feature_name as type, variants, count, entropy, compliance FROM fingerprint_analytics f WHERE f.workspace_id ${workspaceId === "*" ? "!=" : "="} $1 AND ${type ? "f.feature_name = $2" : "true"} ORDER BY entropy DESC`; return doWithClient<FingerprintUsage[]>(sql, clientFactory, async client => { const params = [workspaceId]; if (!!type) { params.push(type); } const rows = await client.query(sql, params); return rows.rows.map(r => ({ name: r.name, type: r.type, variants: +r.variants, count: +r.count, entropy: +r.entropy, compliance: +r.compliance, entropy_band: bandFor(EntropySizeBands, +r.entropy, { casing: BandCasing.Sentence, includeNumber: false }), })); }, []); } /** * Delete the data we hold for this repository. */ async function deleteOldSnapshotForRepository(repoRef: RepoRef, client: ClientBase): Promise<void> { const deleteFingperintsSql = `DELETE from repo_fingerprints WHERE repo_snapshot_id IN (SELECT id from repo_snapshots WHERE url = $1)`; await client.query(deleteFingperintsSql, [repoRef.url]); await client.query(`DELETE from repo_snapshots WHERE url = $1`, [repoRef.url]); } function rowToRepoRef(row: { provider_id: string, owner: string, name: string, url: string, sha?: string, commit_sha?: string }): RemoteRepoRef { return GitHubRepoRef.from({ ...row, sha: row.commit_sha, repo: row.name, }); } function whyDoesPostgresPutANewlineOnSomeFields<T extends { commit_sha?: string, id?: string }>(row: T): T { return { ...row, commit_sha: row.commit_sha ? row.commit_sha.trim() : undefined, id: row.id ? row.id.trim() : undefined, }; } function snapshotIdFor(repoRef: RepoRef): string { return repoRef.url.replace("/", "") + "_" + repoRef.sha; }