diff --git a/destinations/faros-destination/package.json b/destinations/faros-destination/package.json index 6295f4925..fe0e2c166 100644 --- a/destinations/faros-destination/package.json +++ b/destinations/faros-destination/package.json @@ -1,6 +1,6 @@ { "name": "faros-destination", - "version": "0.1.22", + "version": "0.1.23", "private": true, "description": "Faros Destination for Airbyte", "keywords": [ @@ -30,7 +30,7 @@ "watch": "tsc -b -w src test" }, "dependencies": { - "faros-airbyte-cdk": "^0.1.22", + "faros-airbyte-cdk": "^0.1.23", "faros-feeds-sdk": "^0.8.30", "jsonata": "^1.8.5", "object-sizeof": "^1.6.1", diff --git a/destinations/faros-destination/src/index.ts b/destinations/faros-destination/src/index.ts index b9d140413..7b1469ef4 100644 --- a/destinations/faros-destination/src/index.ts +++ b/destinations/faros-destination/src/index.ts @@ -399,7 +399,9 @@ class FarosDestination extends AirbyteDestination { processRecord(); } catch (e: any) { stats.recordsErrored++; - this.logger.error(`Error processing input: ${e.message ?? e}`); + this.logger.error( + `Error processing input: ${e.message ?? JSON.stringify(e)}` + ); if (this.invalidRecordStrategy === InvalidRecordStrategy.FAIL) { throw e; } diff --git a/faros-airbyte-cdk/package.json b/faros-airbyte-cdk/package.json index e5ef271b5..eaf641b11 100644 --- a/faros-airbyte-cdk/package.json +++ b/faros-airbyte-cdk/package.json @@ -1,6 +1,6 @@ { "name": "faros-airbyte-cdk", - "version": "0.1.22", + "version": "0.1.23", "description": "Airbyte Connector Development Kit (CDK) for JavaScript/TypeScript", "keywords": [ "airbyte", diff --git a/faros-airbyte-cdk/src/destinations/destination-runner.ts b/faros-airbyte-cdk/src/destinations/destination-runner.ts index 4452b18b4..d3e1828c9 100644 --- a/faros-airbyte-cdk/src/destinations/destination-runner.ts +++ b/faros-airbyte-cdk/src/destinations/destination-runner.ts @@ -84,7 +84,7 @@ export class AirbyteDestinationRunner { } catch (e: any) { this.logger.error( `Encountered an error while writing to destination: ${ - e.message ?? e + e.message ?? JSON.stringify(e) }` ); throw e; diff --git a/faros-airbyte-cdk/src/sources/source-base.ts b/faros-airbyte-cdk/src/sources/source-base.ts index 4a481d333..2b8e1d5df 100644 --- a/faros-airbyte-cdk/src/sources/source-base.ts +++ b/faros-airbyte-cdk/src/sources/source-base.ts @@ -135,7 +135,7 @@ export abstract class AirbyteSourceBase extends AirbyteSource { } catch (e: any) { this.logger.error( `Encountered an error while reading stream ${this.name}: ${ - e.message ?? e + e.message ?? JSON.stringify(e) }` ); throw e; diff --git a/lerna.json b/lerna.json index 8c25ce8ef..3c8eafcf9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.1.22", + "version": "0.1.23", "packages": [ "faros-airbyte-cdk", "destinations/**", diff --git a/package-lock.json b/package-lock.json index 624e870f8..607c974ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,9 @@ "name": "root", "dependencies": { "@types/jenkins": "^0.23.2", - "async-lock": "^1.3.0", "axios": "^0.21.4", "commander": "^8.2.0", - "condoit": "^2.0.11", + "condoit": "^2.1.0", "faros-feeds-sdk": "^0.8.30", "fast-redact": "^3.0.2", "jenkins": "^0.28.1", @@ -4490,11 +4489,6 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, - "node_modules/async-lock": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.3.0.tgz", - "integrity": "sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5490,9 +5484,9 @@ } }, "node_modules/condoit": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/condoit/-/condoit-2.0.11.tgz", - "integrity": "sha512-ic38W/ZSRPU4PrVupMFwjP3nlo9zNctFedjoGFQ528HYHlmjD0hNL2iU/H6V0ju1ZX0/i3VrQfFxtDmDfSV1YQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/condoit/-/condoit-2.1.0.tgz", + "integrity": "sha512-jRaTbGuF3lj+RQ8hPMNMvhBcsDJpXVYNWMgtx6GCLdpDuRHE74gx8G4jUQfRHATjv2OaRANf/6gBNm5uTj7ZhA==", "dependencies": { "axios": "^0.21.4", "qs": "^6.10.1" @@ -20144,11 +20138,6 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, - "async-lock": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.3.0.tgz", - "integrity": "sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==" - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -20933,9 +20922,9 @@ } }, "condoit": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/condoit/-/condoit-2.0.11.tgz", - "integrity": "sha512-ic38W/ZSRPU4PrVupMFwjP3nlo9zNctFedjoGFQ528HYHlmjD0hNL2iU/H6V0ju1ZX0/i3VrQfFxtDmDfSV1YQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/condoit/-/condoit-2.1.0.tgz", + "integrity": "sha512-jRaTbGuF3lj+RQ8hPMNMvhBcsDJpXVYNWMgtx6GCLdpDuRHE74gx8G4jUQfRHATjv2OaRANf/6gBNm5uTj7ZhA==", "requires": { "axios": "^0.21.4", "qs": "^6.10.1" diff --git a/sources/example-source/package.json b/sources/example-source/package.json index 0c237c68e..f46ee1ccd 100644 --- a/sources/example-source/package.json +++ b/sources/example-source/package.json @@ -1,6 +1,6 @@ { "name": "example-source", - "version": "0.1.22", + "version": "0.1.23", "description": "Example Airbyte source", "keywords": [ "airbyte", @@ -31,7 +31,7 @@ "dependencies": { "axios": "^0.21.4", "commander": "^8.2.0", - "faros-airbyte-cdk": "^0.1.22", + "faros-airbyte-cdk": "^0.1.23", "verror": "^1.10.0" }, "jest": { diff --git a/sources/jenkins-source/package.json b/sources/jenkins-source/package.json index e15d521ea..14533d309 100644 --- a/sources/jenkins-source/package.json +++ b/sources/jenkins-source/package.json @@ -1,6 +1,6 @@ { "name": "jenkins-source", - "version": "0.1.22", + "version": "0.1.23", "description": "Jenkins Airbyte source", "keywords": [ "airbyte", @@ -31,7 +31,7 @@ }, "dependencies": { "axios": "^0.21.4", - "faros-airbyte-cdk": "^0.1.22", + "faros-airbyte-cdk": "^0.1.23", "jenkins": "^0.28.1", "typescript-memoize": "^1.0.1", "verror": "^1.10.0" diff --git a/sources/phabricator-source/package.json b/sources/phabricator-source/package.json index 5db162e05..ae3c91d25 100644 --- a/sources/phabricator-source/package.json +++ b/sources/phabricator-source/package.json @@ -1,6 +1,6 @@ { "name": "phabricator-source", - "version": "0.1.22", + "version": "0.1.23", "description": "Phabricator Airbyte source", "keywords": [ "airbyte", @@ -30,11 +30,10 @@ "watch": "tsc -b -w src test" }, "dependencies": { - "async-lock": "^1.3.0", "axios": "^0.21.4", "commander": "^8.2.0", - "condoit": "^2.0.11", - "faros-airbyte-cdk": "^0.1.22", + "condoit": "^2.1.0", + "faros-airbyte-cdk": "^0.1.23", "moment": "^2.29.1", "verror": "^1.10.0" }, diff --git a/sources/phabricator-source/resources/schemas/commits.json b/sources/phabricator-source/resources/schemas/commits.json index 143bc616b..c0036ff8a 100644 --- a/sources/phabricator-source/resources/schemas/commits.json +++ b/sources/phabricator-source/resources/schemas/commits.json @@ -150,7 +150,9 @@ "properties": { "projectPHIDs": { "type": "array", - "items": {} + "items": { + "type": "string" + } } }, "required": [ @@ -309,7 +311,9 @@ "properties": { "projectPHIDs": { "type": "array", - "items": {} + "items": { + "type": "string" + } } }, "required": [ @@ -1243,4 +1247,4 @@ "attachments", "repository" ] -} \ No newline at end of file +} diff --git a/sources/phabricator-source/resources/schemas/projects.json b/sources/phabricator-source/resources/schemas/projects.json new file mode 100644 index 000000000..ee3e6363a --- /dev/null +++ b/sources/phabricator-source/resources/schemas/projects.json @@ -0,0 +1,242 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "phid": { + "type": "string" + }, + "fields": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "subtype": { + "type": "string" + }, + "milestone": { + "type": "null" + }, + "depth": { + "type": "integer" + }, + "parent": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "phid": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "phid", + "name" + ] + }, + "icon": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "key", + "name", + "icon" + ] + }, + "color": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "key", + "name" + ] + }, + "spacePHID": { + "type": "null" + }, + "dateCreated": { + "type": "integer" + }, + "dateModified": { + "type": "integer" + }, + "policy": { + "type": "object", + "properties": { + "view": { + "type": "string" + }, + "edit": { + "type": "string" + }, + "join": { + "type": "string" + } + }, + "required": [ + "view", + "edit", + "join" + ] + }, + "description": { + "type": "null" + } + }, + "required": [ + "name", + "slug", + "subtype", + "milestone", + "depth", + "parent", + "icon", + "color", + "spacePHID", + "dateCreated", + "dateModified", + "policy", + "description" + ] + }, + "attachments": { + "type": "object", + "properties": { + "members": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "phid": { + "type": "string" + } + }, + "required": [ + "phid" + ] + }, + { + "type": "object", + "properties": { + "phid": { + "type": "string" + } + }, + "required": [ + "phid" + ] + } + ] + } + }, + "required": [ + "members" + ] + }, + "ancestors": { + "type": "object", + "properties": { + "ancestors": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "phid": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "phid", + "name" + ] + } + ] + } + }, + "required": [ + "ancestors" + ] + }, + "watchers": { + "type": "object", + "properties": { + "watchers": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "phid": { + "type": "string" + } + }, + "required": [ + "phid" + ] + } + ] + } + }, + "required": [ + "watchers" + ] + } + }, + "required": [ + "members", + "ancestors", + "watchers" + ] + } + }, + "required": [ + "id", + "type", + "phid", + "fields", + "attachments" + ] +} diff --git a/sources/phabricator-source/resources/schemas/revisions.json b/sources/phabricator-source/resources/schemas/revisions.json index 4ff0ac00a..7e1602ba9 100644 --- a/sources/phabricator-source/resources/schemas/revisions.json +++ b/sources/phabricator-source/resources/schemas/revisions.json @@ -310,7 +310,9 @@ "properties": { "projectPHIDs": { "type": "array", - "items": {} + "items": { + "type": "string" + } } }, "required": [ diff --git a/sources/phabricator-source/resources/spec.json b/sources/phabricator-source/resources/spec.json index 3e74a1c28..2cb7d54fb 100644 --- a/sources/phabricator-source/resources/spec.json +++ b/sources/phabricator-source/resources/spec.json @@ -44,6 +44,18 @@ ], "description": "List of Phabricator repositories, e.g. `repo-1,repo-2`. If none provided would sync all repositories." }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Projects", + "examples": [ + "my_project_1", + "my_project_2" + ], + "description": "List of Phabricator projects slugs, e.g. `my_project_1,my_project_2`. Slugs are the same as project hashtags. If none provided would sync all projects." + }, "limit": { "type": "integer", "minimum": 1, @@ -54,4 +66,4 @@ } } } -} \ No newline at end of file +} diff --git a/sources/phabricator-source/src/index.ts b/sources/phabricator-source/src/index.ts index 79a17073d..2f99128d0 100644 --- a/sources/phabricator-source/src/index.ts +++ b/sources/phabricator-source/src/index.ts @@ -10,7 +10,7 @@ import { import VError from 'verror'; import {Phabricator, PhabricatorConfig} from './phabricator'; -import {Commits, Repositories, Revisions, Users} from './streams'; +import {Commits, Projects, Repositories, Revisions, Users} from './streams'; /** The main entry point. */ export function mainCommand(): Command { @@ -42,6 +42,7 @@ class PhabricatorSource extends AirbyteSourceBase { new Commits(config as PhabricatorConfig, this.logger), new Revisions(config as PhabricatorConfig, this.logger), new Users(config as PhabricatorConfig, this.logger), + new Projects(config as PhabricatorConfig, this.logger), ]; } } diff --git a/sources/phabricator-source/src/phabricator.ts b/sources/phabricator-source/src/phabricator.ts index 1dca15e63..404242db7 100644 --- a/sources/phabricator-source/src/phabricator.ts +++ b/sources/phabricator-source/src/phabricator.ts @@ -1,3 +1,4 @@ +import Axios from 'axios'; import {Condoit} from 'condoit'; import iDiffusion from 'condoit/dist/interfaces/iDiffusion'; import { @@ -5,6 +6,7 @@ import { phid, RetSearchConstants, } from 'condoit/dist/interfaces/iGlobal'; +import iProject from 'condoit/dist/interfaces/iProject'; import iUser from 'condoit/dist/interfaces/iUser'; import {AirbyteLogger} from 'faros-airbyte-cdk'; import {trim, uniq} from 'lodash'; @@ -18,7 +20,8 @@ export interface PhabricatorConfig { readonly server_url: string; readonly token: string; readonly start_date: string; - readonly repositories: string; + readonly repositories: string | string[]; + readonly projects: string | string[]; readonly limit: number; } @@ -28,6 +31,7 @@ export interface Commit extends iDiffusion.retDiffusionCommitSearchData { repository?: Repository; } export type User = iUser.retUsersSearchData; +export type Project = iProject.retProjectSearchData; export interface Revision extends RetSearchConstants { // Added full repository information as well repository?: Repository; @@ -88,6 +92,7 @@ interface PagedResult extends ErrorCodes { } export class Phabricator { + private static phabricator: Phabricator = null; private static repoCacheById: Dictionary = {}; private static repoCacheByName: Dictionary = {}; @@ -95,6 +100,7 @@ export class Phabricator { readonly client: Condoit, readonly startDate: Moment, readonly repositories: string[], + readonly projects: string[], readonly limit: number, readonly logger: AirbyteLogger ) {} @@ -103,9 +109,19 @@ export class Phabricator { config: PhabricatorConfig, logger: AirbyteLogger ): Phabricator { + if (Phabricator.phabricator) return Phabricator.phabricator; + + let baseURL: string; if (!config.server_url) { throw new VError('server_url is null or empty'); } + try { + baseURL = new URL('/api', config.server_url).toString(); + } catch (e: any) { + throw new VError( + `server_url is invalid - ${e.message ?? JSON.stringify(e)}` + ); + } if (!config.start_date) { throw new VError('start_date is null or empty'); } @@ -114,6 +130,7 @@ export class Phabricator { throw new VError('start_date is invalid: %s', config.start_date); } const repositories = Phabricator.toStringArray(config.repositories); + const projects = Phabricator.toStringArray(config.projects); const limit = config.limit && config.limit > 0 && @@ -121,9 +138,20 @@ export class Phabricator { ? config.limit : PHABRICATOR_DEFAULT_LIMIT; - const client = new Condoit(config.server_url, config.token); + const axios = Axios.create({baseURL, timeout: 30000}); + const client = new Condoit(config.server_url, config.token, {}, axios); - return new Phabricator(client, startDate, repositories, limit, logger); + Phabricator.phabricator = new Phabricator( + client, + startDate, + repositories, + projects, + limit, + logger + ); + logger.debug('Created Phabricator instance'); + + return Phabricator.phabricator; } private static toStringArray(s: any, sep = ','): string[] { @@ -183,7 +211,7 @@ export class Phabricator { const created = Math.max(createdAt ?? 0, this.startDate.unix()); this.logger.debug(`Fetching repositories created since ${created}`); - const attachments = {projects: false, uris: true, metrics: true}; + const attachments = {projects: true, uris: true, metrics: true}; let constraints = {}; if (filter.repoIds?.length > 0 || filter.repoNames?.length > 0) { @@ -312,7 +340,7 @@ export class Phabricator { } const constraints = {repositoryPHIDs, modifiedStart: modified}; - const attachments = {projects: false, subscribers: true, reviewers: true}; + const attachments = {projects: true, subscribers: true, reviewers: true}; yield* this.paginate( limit, @@ -376,4 +404,38 @@ export class Phabricator { } ); } + + async *getProjects( + filter: { + slugs?: string[]; + }, + createdAt?: number, + limit = this.limit + ): AsyncGenerator { + const created = Math.max(createdAt ?? 0, this.startDate.unix()); + this.logger.debug(`Fetching projects created since ${created}`); + + const constraints = {slugs: filter.slugs ?? []}; + const attachments = {members: true, ancestors: true, watchers: true}; + + yield* this.paginate( + limit, + (after) => { + return this.client.project.search({ + queryKey: 'all', + order: 'newest' as any, + constraints, + attachments, + limit, + after, + }); + }, + async (projects) => { + const newProjects = projects.filter( + (project) => project.fields.dateCreated > created + ); + return newProjects; + } + ); + } } diff --git a/sources/phabricator-source/src/streams/index.ts b/sources/phabricator-source/src/streams/index.ts index 2d60d4cf2..4d5d6fbf5 100644 --- a/sources/phabricator-source/src/streams/index.ts +++ b/sources/phabricator-source/src/streams/index.ts @@ -1,6 +1,7 @@ import {Commits} from './commits'; +import {Projects} from './projects'; import {Repositories} from './repositories'; import {Revisions} from './revisions'; import {Users} from './users'; -export {Commits, Repositories, Revisions, Users}; +export {Commits, Projects, Repositories, Revisions, Users}; diff --git a/sources/phabricator-source/src/streams/projects.ts b/sources/phabricator-source/src/streams/projects.ts new file mode 100644 index 000000000..129cf506c --- /dev/null +++ b/sources/phabricator-source/src/streams/projects.ts @@ -0,0 +1,52 @@ +import { + AirbyteLogger, + AirbyteStreamBase, + StreamKey, + SyncMode, +} from 'faros-airbyte-cdk'; +import {Dictionary} from 'ts-essentials'; + +import {Phabricator, PhabricatorConfig, Project} from '../phabricator'; + +export interface ProjectsState { + latestCreatedAt: number; +} + +export class Projects extends AirbyteStreamBase { + constructor( + private readonly config: PhabricatorConfig, + protected readonly logger: AirbyteLogger + ) { + super(logger); + } + getJsonSchema(): Dictionary { + return require('../../resources/schemas/projects.json'); + } + get primaryKey(): StreamKey { + throw 'phid'; + } + get cursorField(): string[] { + return ['fields', 'dateCreated']; + } + getUpdatedState( + currentStreamState: ProjectsState, + latestRecord: Project + ): ProjectsState { + const latestCreated = currentStreamState?.latestCreatedAt ?? 0; + const recordCreated = latestRecord.fields?.dateCreated ?? 0; + currentStreamState.latestCreatedAt = Math.max(latestCreated, recordCreated); + return currentStreamState; + } + async *readRecords( + syncMode: SyncMode, + cursorField?: string[], + streamSlice?: Dictionary, + streamState?: ProjectsState + ): AsyncGenerator { + const phabricator = Phabricator.instance(this.config, this.logger); + const state = syncMode === SyncMode.INCREMENTAL ? streamState : undefined; + const createdAt = state?.latestCreatedAt ?? 0; + + yield* phabricator.getProjects({slugs: phabricator.projects}, createdAt); + } +} diff --git a/sources/phabricator-source/test_files/full_configured_catalog.json b/sources/phabricator-source/test_files/full_configured_catalog.json index 0e5efc43e..839ac816a 100644 --- a/sources/phabricator-source/test_files/full_configured_catalog.json +++ b/sources/phabricator-source/test_files/full_configured_catalog.json @@ -72,6 +72,24 @@ }, "sync_mode": "full_refresh", "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "projects", + "json_schema": {}, + "supported_sync_modes": [ + "full_refresh", + "incremental" + ], + "source_defined_cursor": true, + "source_defined_primary_key": [ + [ + "phid" + ] + ] + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite" } ] } diff --git a/sources/phabricator-source/test_files/incremental_configured_catalog.json b/sources/phabricator-source/test_files/incremental_configured_catalog.json index ecb5190c7..6fa5685ab 100644 --- a/sources/phabricator-source/test_files/incremental_configured_catalog.json +++ b/sources/phabricator-source/test_files/incremental_configured_catalog.json @@ -97,6 +97,30 @@ "dateCreated" ] ] + }, + { + "stream": { + "name": "projects", + "json_schema": {}, + "supported_sync_modes": [ + "full_refresh", + "incremental" + ], + "source_defined_cursor": true, + "source_defined_primary_key": [ + [ + "phid" + ] + ] + }, + "sync_mode": "incremental", + "destination_sync_mode": "append", + "cursor_field": [ + [ + "fields", + "dateCreated" + ] + ] } ] } diff --git a/sources/phabricator-source/test_files/spec.json b/sources/phabricator-source/test_files/spec.json index 3e74a1c28..2cb7d54fb 100644 --- a/sources/phabricator-source/test_files/spec.json +++ b/sources/phabricator-source/test_files/spec.json @@ -44,6 +44,18 @@ ], "description": "List of Phabricator repositories, e.g. `repo-1,repo-2`. If none provided would sync all repositories." }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Projects", + "examples": [ + "my_project_1", + "my_project_2" + ], + "description": "List of Phabricator projects slugs, e.g. `my_project_1,my_project_2`. Slugs are the same as project hashtags. If none provided would sync all projects." + }, "limit": { "type": "integer", "minimum": 1, @@ -54,4 +66,4 @@ } } } -} \ No newline at end of file +}