diff --git a/cli/src/commands/stats.ts b/cli/src/commands/stats.ts index 5107eeaf7..75ea97e69 100644 --- a/cli/src/commands/stats.ts +++ b/cli/src/commands/stats.ts @@ -1,20 +1,65 @@ +// Vendor +import * as chalk from 'chalk'; + // Command +import {flags} from '@oclif/command'; import Command, {configFlag} from '../base'; +import {CLIError} from '@oclif/errors'; // Services import Formatter from '../services/formatters/project-stats'; +import ProjectFetcher from '../services/project-fetcher'; +import {Revision} from '../types/project'; export default class Stats extends Command { - static description = 'Fetch stats from the API and display it beautifully'; + static description = 'Fetch stats from the API and display them beautifully'; static examples = [`$ accent stats`]; - static flags = {config: configFlag}; + static flags = { + version: flags.string({ + default: undefined, + description: 'View stats for a specific version', + }), + 'check-reviewed': flags.boolean({ + description: 'Exit 1 when reviewed percentage is not 100%', + }), + config: configFlag, + }; - /* eslint-disable @typescript-eslint/require-await */ async run() { - const formatter = new Formatter(this.project!, this.projectConfig.config); + const {flags} = this.parse(Stats); + + if (flags.version) { + const config = this.projectConfig.config; + const fetcher = new ProjectFetcher(); + const response = await fetcher.fetch(config, {versionId: flags.version}); + + this.project = response.project; + } + + const formatter = new Formatter( + this.project!, + this.projectConfig.config, + flags.version + ); formatter.log(); + + if (flags['check-reviewed']) { + const conflictsCount = this.project!.revisions.reduce( + (memo, revision: Revision) => memo + revision.conflictsCount, + 0 + ); + + if (conflictsCount !== 0) { + const versionFormat = flags.version ? ` ${flags.version}` : ''; + throw new CLIError( + chalk.red( + `Project${versionFormat} has ${conflictsCount} strings to be reviewed` + ), + {exit: 1} + ); + } + } } - /* eslint-enable @typescript-eslint/require-await */ } diff --git a/cli/src/services/formatters/project-stats.ts b/cli/src/services/formatters/project-stats.ts index 08f6e2c60..a3f83d29c 100644 --- a/cli/src/services/formatters/project-stats.ts +++ b/cli/src/services/formatters/project-stats.ts @@ -18,16 +18,43 @@ import { } from '../revision-slug-fetcher'; import Base from './base'; -const TITLE_LENGTH_PADDING = 4; - export default class ProjectStatsFormatter extends Base { private readonly project: Project; private readonly config: Config; + private readonly version: string | undefined; - constructor(project: Project, config: Config) { + constructor(project: Project, config: Config, version?: string) { super(); this.project = project; this.config = config; + this.version = version; + } + + percentageReviewedString(number: number, translationsCount: number) { + const prettyFloat = (number: string) => { + if (number.endsWith('.00')) { + return parseInt(number, 10).toString(); + } else { + return number; + } + }; + + const percentageReviewedString = `${prettyFloat( + number.toFixed(2) + )}% reviewed`; + let percentageReviewedFormat = chalk.green(percentageReviewedString); + + if (number === 100) { + percentageReviewedFormat = chalk.green(percentageReviewedString); + } else if (number > 100 / 2) { + percentageReviewedFormat = chalk.yellow(percentageReviewedString); + } else if (number <= 0 && translationsCount === 0) { + percentageReviewedFormat = chalk.dim('No strings'); + } else { + percentageReviewedFormat = chalk.red(percentageReviewedString); + } + + return percentageReviewedFormat; } log() { @@ -44,20 +71,12 @@ export default class ProjectStatsFormatter extends Base { 0 ); const percentageReviewed = - translationsCount > 0 ? reviewedCount / translationsCount : 0; + translationsCount > 0 ? (reviewedCount / translationsCount) * 100 : 0; - const percentageReviewedString = `${percentageReviewed}% reviewed`; - let percentageReviewedFormat = chalk.green(percentageReviewedString); - - if (percentageReviewed === 100) { - percentageReviewedFormat = chalk.green(percentageReviewedString); - } else if (percentageReviewed > 100 / 2) { - percentageReviewedFormat = chalk.yellow(percentageReviewedString); - } else if (percentageReviewed <= 0 && translationsCount === 0) { - percentageReviewedFormat = chalk.dim('No strings'); - } else { - percentageReviewedFormat = chalk.red(percentageReviewedString); - } + const percentageReviewedFormat = this.percentageReviewedString( + percentageReviewed, + translationsCount + ); console.log( this.project.logo @@ -67,12 +86,7 @@ export default class ProjectStatsFormatter extends Base { chalk.dim(' • '), percentageReviewedFormat ); - const titleLength = - (this.project.logo ? this.project.logo.length + 1 : 0) + - this.project.name.length + - percentageReviewedString.length + - TITLE_LENGTH_PADDING; - console.log(chalk.gray.dim('⎯'.repeat(titleLength))); + console.log(chalk.gray.dim('⎯')); console.log(chalk.magenta('Last synced')); if (this.project.lastSyncedAt) { @@ -99,18 +113,12 @@ export default class ProjectStatsFormatter extends Base { this.project.revisions.forEach((revision: Revision) => { if (this.project.masterRevision.id !== revision.id) { const percentageReviewed = - revision.reviewedCount / revision.translationsCount; - - const percentageReviewedString = `${percentageReviewed}% reviewed`; - let percentageReviewedFormat = chalk.green(percentageReviewedString); + (revision.reviewedCount / revision.translationsCount) * 100; - if (percentageReviewed === 100) { - percentageReviewedFormat = chalk.green(percentageReviewedString); - } else if (percentageReviewed > 100 / 2) { - percentageReviewedFormat = chalk.yellow(percentageReviewedString); - } else { - percentageReviewedFormat = chalk.red(percentageReviewedString); - } + const percentageReviewedFormat = this.percentageReviewedString( + percentageReviewed, + translationsCount + ); console.log( `${chalk.white.bold( @@ -147,13 +155,16 @@ export default class ProjectStatsFormatter extends Base { ) ); this.project.versions.entries.forEach((version: Version) => { - console.log(chalk.bgBlack.white(` ${version.tag} `)); + if (version.tag === this.version) { + console.log(chalk.bgBlack.whiteBright(` ${version.tag} `)); + } else { + console.log(chalk.white(`${version.tag}`)); + } }); console.log(''); } - console.log(chalk.magenta('Strings')); - console.log(chalk.white('# Strings:'), chalk.white(`${translationsCount}`)); + console.log(chalk.magenta(`Strings (${translationsCount})`)); console.log(chalk.green('✓ Reviewed:'), chalk.green(`${reviewedCount}`)); console.log(chalk.red('× In review:'), chalk.red(`${conflictsCount}`)); console.log(''); diff --git a/cli/src/services/project-fetcher.ts b/cli/src/services/project-fetcher.ts index c5c29268c..e4da0bd7a 100644 --- a/cli/src/services/project-fetcher.ts +++ b/cli/src/services/project-fetcher.ts @@ -8,8 +8,8 @@ import {Config} from '../types/config'; import {ProjectViewer} from '../types/project'; export default class ProjectFetcher { - async fetch(config: Config): Promise { - const response = await this.graphql(config); + async fetch(config: Config, params?: object): Promise { + const response = await this.graphql(config, params || {}); try { const data = (await response.json()) as {data: any}; @@ -31,14 +31,14 @@ export default class ProjectFetcher { } } - private async graphql(config: Config) { - const query = `query ProjectDetails($project_id: ID!) { + private async graphql(config: Config, params: object) { + const query = `query ProjectDetails($projectId: ID! $versionId: ID) { viewer { user { fullname } - project(id: $project_id) { + project(id: $projectId) { id name logo @@ -88,7 +88,7 @@ export default class ProjectFetcher { } } - revisions { + revisions(versionId: $versionId) { id isMaster translationsCount @@ -106,8 +106,8 @@ export default class ProjectFetcher { } }`; - // eslint-disable-next-line camelcase - const variables = config.project ? {project_id: config.project} : {}; + const configParams = config.project ? {projectId: config.project} : {}; + const variables = {...configParams, ...params}; return await fetch(`${config.apiUrl}/graphql`, { body: JSON.stringify({query, variables}), diff --git a/lib/accent/scopes/revision.ex b/lib/accent/scopes/revision.ex index 5bf9424df..2660f892a 100644 --- a/lib/accent/scopes/revision.ex +++ b/lib/accent/scopes/revision.ex @@ -65,8 +65,8 @@ defmodule Accent.Scopes.Revision do @doc """ Fill `translations_count`, `conflicts_count` and `reviewed_count` for revisions. """ - @spec with_stats(Ecto.Queryable.t()) :: Ecto.Queryable.t() - def with_stats(query) do - Accent.Scopes.TranslationsCount.with_stats(query, :revision_id) + @spec with_stats(Ecto.Queryable.t(), Keyword.t() | nil) :: Ecto.Queryable.t() + def with_stats(query, options \\ []) do + Accent.Scopes.TranslationsCount.with_stats(query, :revision_id, options) end end diff --git a/lib/accent/scopes/translations_count.ex b/lib/accent/scopes/translations_count.ex index 8c928486f..a5633193d 100644 --- a/lib/accent/scopes/translations_count.ex +++ b/lib/accent/scopes/translations_count.ex @@ -4,16 +4,26 @@ defmodule Accent.Scopes.TranslationsCount do def with_stats(query, column, options \\ []) do exclude_empty_translations = Keyword.get(options, :exclude_empty_translations, false) + version_id = Keyword.get(options, :version_id, nil) translations = from( t in Accent.Translation, select: %{field_id: field(t, ^column), count: count(t)}, where: [removed: false, locked: false], - where: is_nil(t.version_id), group_by: field(t, ^column) ) + translations = + if version_id do + from(t in translations, + inner_join: versions in assoc(t, :version), + where: versions.tag == ^version_id + ) + else + from(t in translations, where: is_nil(t.version_id)) + end + query = query |> count_translations(translations, exclude_empty_translations) diff --git a/lib/graphql/resolvers/revision.ex b/lib/graphql/resolvers/revision.ex index 66c5486c7..06053ee63 100644 --- a/lib/graphql/resolvers/revision.ex +++ b/lib/graphql/resolvers/revision.ex @@ -113,31 +113,31 @@ defmodule Accent.GraphQL.Resolvers.Revision do end @spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Revision.t() | nil} - def show_project(project, %{id: id}, _) do + def show_project(project, %{id: id} = args, _) do Revision |> RevisionScope.from_project(project.id) - |> RevisionScope.with_stats() + |> RevisionScope.with_stats(version_id: args[:version_id]) |> Query.where(id: ^id) |> Repo.one() |> then(&{:ok, &1}) end - def show_project(project, _, _) do + def show_project(project, args, _) do Revision |> RevisionScope.from_project(project.id) - |> RevisionScope.with_stats() + |> RevisionScope.with_stats(version_id: args[:version_id]) |> RevisionScope.master() |> Repo.one() |> then(&{:ok, &1}) end @spec list_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, [Revision.t()]} - def list_project(project, _, _) do + def list_project(project, args, _) do project |> Ecto.assoc(:revisions) |> Query.join(:inner, [revisions], languages in assoc(revisions, :language), as: :languages) |> Query.order_by([revisions, languages: languages], desc: :master, asc: revisions.name, asc: languages.name) - |> RevisionScope.with_stats() + |> RevisionScope.with_stats(version_id: args[:version_id]) |> Repo.all() |> then(&{:ok, &1}) end diff --git a/lib/graphql/types/project.ex b/lib/graphql/types/project.ex index 737ac8266..c2b029932 100644 --- a/lib/graphql/types/project.ex +++ b/lib/graphql/types/project.ex @@ -192,11 +192,13 @@ defmodule Accent.GraphQL.Types.Project do field :revision, :revision do arg(:id, :id) + arg(:version_id, :id) resolve(project_authorize(:show_revision, &Accent.GraphQL.Resolvers.Revision.show_project/3)) end field :revisions, list_of(:revision) do + arg(:version_id, :id) resolve(project_authorize(:index_revisions, &Accent.GraphQL.Resolvers.Revision.list_project/3)) end