feat(catalog-backend): use scm integration for codeowners

Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
Andrew Thauer
2021-03-15 07:30:17 -04:00
parent 1cc27f7e10
commit 4bc98a5b99
10 changed files with 416 additions and 328 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Refactor CodeOwnersProcessor to use ScmIntegrations
@@ -16,59 +16,23 @@
import { getVoidLogger } from '@backstage/backend-common';
import { LocationSpec } from '@backstage/catalog-model';
import { CodeOwnersEntry } from 'codeowners-utils';
import {
buildCodeOwnerUrl,
buildUrl,
CodeOwnersProcessor,
findPrimaryCodeOwner,
findRawCodeOwners,
normalizeCodeOwner,
parseCodeOwners,
resolveCodeOwner,
} from './CodeOwnersProcessor';
import { ConfigReader } from '@backstage/config';
import { CodeOwnersProcessor } from './CodeOwnersProcessor';
const logger = getVoidLogger();
const mockCodeOwnersText = () => `
* @acme/team-foo @acme/team-bar
/docs @acme/team-bar
`;
describe('CodeOwnersProcessor', () => {
const mockUrl = ({ basePath = '' } = {}): string =>
`https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`;
const mockLocation = ({
basePath = '',
type = 'github',
} = {}): LocationSpec => ({
type,
target: mockUrl({ basePath }),
target: `https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`,
});
const mockReadUrl = (basePath = '') =>
`https://github.com/backstage/backstage/blob/master/${basePath}CODEOWNERS`;
const mockGitUri = (codeOwnersPath: string = '') => {
return {
source: 'github.com',
owner: 'backstage',
name: 'backstage',
codeOwnersPath,
};
};
const mockCodeOwnersText = () => `
# https://help.github.com/articles/about-codeowners/
* @spotify/backstage-core @acme/team-foo
/plugins/techdocs @spotify/techdocs-core
`;
const mockCodeOwners = (): CodeOwnersEntry[] => {
return [
{
pattern: '/plugins/techdocs',
owners: ['@spotify/techdocs-core'],
},
{ pattern: '*', owners: ['@spotify/backstage-core', '@acme/team-foo'] },
];
};
const mockReadResult = ({
error = undefined,
data = undefined,
@@ -82,156 +46,19 @@ describe('CodeOwnersProcessor', () => {
return data;
};
describe('buildUrl', () => {
it.each([['azure.com'], ['dev.azure.com']])(
'should throw not implemented error',
source => {
expect(() => buildUrl({ ...mockGitUri(), source })).toThrow();
},
);
it('should build github.com url', () => {
expect(
buildUrl({
...mockGitUri(),
codeOwnersPath: '/.github/CODEOWNERS',
}),
).toBe(
'https://github.com/backstage/backstage/blob/master/.github/CODEOWNERS',
);
});
});
describe('buildCodeOwnerUrl', () => {
it('should build a location spec to the codeowners', () => {
expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual(
'https://github.com/backstage/backstage/blob/master/docs/CODEOWNERS',
);
});
it('should handle nested paths from original location spec', () => {
expect(
buildCodeOwnerUrl(
mockUrl({ basePath: 'packages/foo/' }),
'/CODEOWNERS',
),
).toEqual(
'https://github.com/backstage/backstage/blob/master/CODEOWNERS',
);
});
});
describe('parseCodeOwners', () => {
it('should parse the codeowners file', () => {
expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners());
});
});
describe('normalizeCodeOwner', () => {
it('should remove the @ symbol', () => {
expect(normalizeCodeOwner('@yoda')).toBe('yoda');
});
it('should remove org from org/team format', () => {
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
});
it('should return username from email format', () => {
expect(normalizeCodeOwner('foo@acme.com')).toBe('foo');
});
it.each([['acme/foo'], ['dacme/foo']])(
'should return string everything else',
owner => {
expect(normalizeCodeOwner(owner)).toBe(owner);
},
);
});
describe('findPrimaryCodeOwner', () => {
it('should return the primary owner', () => {
expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core');
});
});
describe('findRawCodeOwners', () => {
it('should return found codeowner', async () => {
const ownersText = mockCodeOwnersText();
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(result).toEqual(ownersText);
});
it('should return undefined when no codeowner', async () => {
const read = jest.fn().mockRejectedValue(mockReadResult());
const reader = { read, readTree: jest.fn(), search: jest.fn() };
await expect(
findRawCodeOwners(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
it('should look at known codeowner locations', async () => {
const ownersText = mockCodeOwnersText();
const read = jest
.fn()
.mockImplementationOnce(() => mockReadResult({ error: 'foo' }))
.mockImplementationOnce(() => mockReadResult({ error: 'bar' }))
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const result = await findRawCodeOwners(mockLocation(), {
reader,
logger,
});
expect(read.mock.calls.length).toBe(5);
expect(read.mock.calls[0]).toEqual([mockReadUrl('')]);
expect(read.mock.calls[1]).toEqual([mockReadUrl('docs/')]);
expect(read.mock.calls[2]).toEqual([mockReadUrl('.bitbucket/')]);
expect(read.mock.calls[3]).toEqual([mockReadUrl('.github/')]);
expect(read.mock.calls[4]).toEqual([mockReadUrl('.gitlab/')]);
expect(result).toEqual(ownersText);
});
});
describe('resolveCodeOwner', () => {
it('should return found codeowner', async () => {
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const owner = await resolveCodeOwner(mockLocation(), { reader, logger });
expect(owner).toBe('backstage-core');
});
it('should return undefined when no codeowner', async () => {
const read = jest
.fn()
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
await expect(
resolveCodeOwner(mockLocation(), { reader, logger }),
).resolves.toBeUndefined();
});
});
describe('CodeOwnersProcessor', () => {
describe('preProcessEntity', () => {
const setupTest = ({ kind = 'Component', spec = {} } = {}) => {
const entity = { kind, spec };
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
const config = new ConfigReader({});
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const processor = new CodeOwnersProcessor({ reader, logger });
const processor = CodeOwnersProcessor.fromConfig(config, {
logger: getVoidLogger(),
reader,
});
return { entity, processor, read };
};
@@ -249,18 +76,15 @@ describe('CodeOwnersProcessor', () => {
expect(result).toEqual(entity);
});
it('should handle url locations', async () => {
it('should ingore invalid locations type', async () => {
const { entity, processor } = setupTest();
const result = await processor.preProcessEntity(
entity as any,
mockLocation({ type: 'url' }),
mockLocation({ type: 'github-org' }),
);
expect(result).toEqual({
...entity,
spec: { owner: 'backstage-core' },
});
expect(result).toEqual(entity);
});
it('should ignore invalid kinds', async () => {
@@ -284,7 +108,7 @@ describe('CodeOwnersProcessor', () => {
expect(result).toEqual({
...entity,
spec: { owner: 'backstage-core' },
spec: { owner: 'team-foo' },
});
});
});
@@ -15,19 +15,11 @@
*/
import { UrlReader } from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import {
Entity,
LocationSpec,
stringifyLocationReference,
} from '@backstage/catalog-model';
import * as codeowners from 'codeowners-utils';
import { CodeOwnersEntry } from 'codeowners-utils';
// NOTE: This can be removed when ES2021 is implemented
import 'core-js/features/promise';
import parseGitUrl from 'git-url-parse';
import { filter, get, head, pipe, reverse } from 'lodash/fp';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { Logger } from 'winston';
import { findCodeOwnerByTarget } from './codeowners';
import { CatalogProcessor } from './types';
const ALLOWED_KINDS = ['API', 'Component', 'Domain', 'Resource', 'System'];
@@ -42,18 +34,32 @@ const ALLOWED_LOCATION_TYPES = [
'gitlab/api',
];
// TODO(Rugvip): We want to properly detect out repo provider, but for now it's
// best to wait for GitHub Apps to be properly introduced and see
// what kind of APIs that integrations will expose.
const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab'];
type Options = {
reader: UrlReader;
logger: Logger;
};
export class CodeOwnersProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
private readonly reader: UrlReader;
static fromConfig(
config: Config,
options: { logger: Logger; reader: UrlReader },
) {
const integrations = ScmIntegrations.fromConfig(config);
return new CodeOwnersProcessor({
...options,
integrations,
});
}
constructor(options: {
integrations: ScmIntegrations;
logger: Logger;
reader: UrlReader;
}) {
this.integrations = options.integrations;
this.logger = options.logger;
this.reader = options.reader;
}
async preProcessEntity(
entity: Entity,
@@ -69,8 +75,21 @@ export class CodeOwnersProcessor implements CatalogProcessor {
return entity;
}
const owner = await resolveCodeOwner(location, this.options);
const scmIntegration = this.integrations.byUrl(location.target);
if (!scmIntegration) {
return entity;
}
const owner = await findCodeOwnerByTarget(
this.reader,
location.target,
scmIntegration,
);
if (!owner) {
this.logger.debug(
`CodeOwnerProcessor could not resolve owner for ${location.target}`,
);
return entity;
}
@@ -80,112 +99,3 @@ export class CodeOwnersProcessor implements CatalogProcessor {
};
}
}
export async function resolveCodeOwner(
location: LocationSpec,
options: Options,
): Promise<string | undefined> {
const ownersText = await findRawCodeOwners(location, options);
if (!ownersText) {
return undefined;
}
const owners = parseCodeOwners(ownersText);
return findPrimaryCodeOwner(owners);
}
export async function findRawCodeOwners(
location: LocationSpec,
options: Options,
): Promise<string | undefined> {
const readOwnerLocation = async (basePath: string): Promise<string> => {
const ownerUrl = buildCodeOwnerUrl(
location.target,
`${basePath}/CODEOWNERS`,
);
const data = await options.reader.read(ownerUrl);
return data.toString();
};
const candidates = KNOWN_LOCATIONS.map(readOwnerLocation);
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
const hardError = aggregateError.errors.find(
error => !(error instanceof NotFoundError),
);
if (hardError) {
options.logger.warn(
`Failed to read codeowners for location ${stringifyLocationReference(
location,
)}, ${hardError}`,
);
} else {
options.logger.debug(
`Failed to find codeowners for location ${stringifyLocationReference(
location,
)}`,
);
}
return undefined;
});
}
export function buildCodeOwnerUrl(
basePath: string,
codeOwnersPath: string,
): string {
return buildUrl({ ...parseGitUrl(basePath), codeOwnersPath });
}
export function parseCodeOwners(ownersText: string) {
return codeowners.parse(ownersText);
}
export function findPrimaryCodeOwner(
owners: CodeOwnersEntry[],
): string | undefined {
return pipe(
filter((e: CodeOwnersEntry) => e.pattern === '*'),
reverse,
head,
get('owners'),
head,
normalizeCodeOwner,
)(owners);
}
export function normalizeCodeOwner(owner: string) {
if (owner.match(/^@.*\/.*/)) {
return owner.split('/')[1];
} else if (owner.match(/^@.*/)) {
return owner.substring(1);
} else if (owner.match(/^.*@.*\..*$/)) {
return owner.split('@')[0];
}
return owner;
}
export function buildUrl({
protocol = 'https',
source = 'github.com',
owner,
name,
ref = 'master',
codeOwnersPath = '/CODEOWNERS',
}: {
protocol?: string;
source?: string;
owner: string;
name: string;
ref?: string;
codeOwnersPath?: string;
}) {
switch (source) {
case 'dev.azure.com':
case 'azure.com':
throw Error('Azure codeowner url builder not implemented');
default:
return `${protocol}://${source}/${owner}/${name}/blob/${ref}${codeOwnersPath}`;
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { findCodeOwnerByTarget, readCodeOwners } from './read';
export { resolveCodeOwner } from './resolve';
export { scmCodeOwnersPaths } from './scm';
@@ -0,0 +1,131 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { findCodeOwnerByTarget, readCodeOwners } from './read';
const sourceUrl = 'https://github.com/acme/foobar/tree/master/';
const mockCodeowners = `
* @acme/team-foo @acme/team-bar
/docs @acme/team-bar
`;
const mockReadResult = ({
error = undefined,
data = undefined,
}: {
error?: string;
data?: string;
} = {}) => {
if (error) {
throw Error(error);
}
return data;
};
describe('readCodeOwners', () => {
it('should return found codeowners file', async () => {
const ownersText = mockCodeowners;
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const result = await readCodeOwners(reader, sourceUrl, [
'.github/CODEOWNERS',
]);
expect(result).toEqual(ownersText);
});
it('should return undefined when no codeowner', async () => {
const read = jest.fn().mockRejectedValue(mockReadResult());
const reader = { read, readTree: jest.fn(), search: jest.fn() };
await expect(
readCodeOwners(reader, sourceUrl, ['.github/CODEOWNERS']),
).resolves.toBeUndefined();
});
it('should look at multiple locations', async () => {
const ownersText = mockCodeowners;
const read = jest
.fn()
.mockImplementationOnce(() => mockReadResult({ error: 'not found' }))
.mockResolvedValue(mockReadResult({ data: ownersText }));
const reader = { read, readTree: jest.fn(), search: jest.fn() };
const result = await readCodeOwners(reader, sourceUrl, [
'.github/CODEOWNERS',
'docs/CODEOWNERS',
]);
expect(read.mock.calls.length).toBe(2);
expect(read.mock.calls[0]).toEqual([`${sourceUrl}.github/CODEOWNERS`]);
expect(read.mock.calls[1]).toEqual([`${sourceUrl}docs/CODEOWNERS`]);
expect(result).toEqual(ownersText);
});
});
describe('findCodeOwnerByLocation', () => {
const setupTest = ({
target = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
codeownersContents: codeOwnersContents = mockCodeowners,
}: { target?: string; codeownersContents?: string } = {}) => {
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: codeOwnersContents }));
const scmIntegration = ScmIntegrations.fromConfig(
new ConfigReader({}),
).byUrl(target);
const reader = { read, readTree: jest.fn(), search: jest.fn() };
return { target, reader, scmIntegration, codeOwnersContents };
};
it('should return an owner', async () => {
const { target, reader, scmIntegration } = setupTest({
target:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
const result = await findCodeOwnerByTarget(
reader,
target,
scmIntegration as any,
);
expect(result).toBe('team-foo');
});
it('should return undefined for invalid scm', async () => {
const { target, reader, scmIntegration } = setupTest({
target:
'https://unknown-git-host/backstage/backstage/blob/master/catalog-info.yaml',
codeownersContents: undefined,
});
const result = await findCodeOwnerByTarget(
reader,
target,
scmIntegration as any,
);
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,75 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { UrlReader } from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import { ScmIntegration } from '@backstage/integration';
import 'core-js/features/promise'; // NOTE: This can be removed when ES2021 is implemented
import { resolveCodeOwner } from './resolve';
import { scmCodeOwnersPaths } from './scm';
export async function readCodeOwners(
reader: UrlReader,
sourceUrl: string,
codeownersPaths: string[],
): Promise<string | undefined> {
const readOwnerLocation = async (path: string): Promise<string> => {
const url = `${sourceUrl}${path}`;
const data = await reader.read(url);
return data.toString();
};
const candidates = codeownersPaths.map(readOwnerLocation);
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
const hardError = aggregateError.errors.find(
error => !(error instanceof NotFoundError),
);
if (hardError) {
throw hardError;
}
return undefined;
});
}
export async function findCodeOwnerByTarget(
reader: UrlReader,
targetUrl: string,
scmIntegration: ScmIntegration,
): Promise<string | undefined> {
const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? ''];
const sourceUrl = scmIntegration?.resolveUrl({
url: '/',
base: targetUrl,
});
if (!sourceUrl || !codeownersPaths) {
return undefined;
}
const contents = await readCodeOwners(reader, sourceUrl, codeownersPaths);
if (!contents) {
return undefined;
}
const owner = resolveCodeOwner(contents);
return owner;
}
@@ -0,0 +1,49 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { normalizeCodeOwner, resolveCodeOwner } from './resolve';
const mockCodeOwnersText = () => `
* @acme/team-foo @acme/team-bar
/docs @acme/team-bar
`;
describe('resolveCodeOwner', () => {
it('should parse the codeowners file', () => {
expect(resolveCodeOwner(mockCodeOwnersText())).toBe('team-foo');
});
});
describe('normalizeCodeOwner', () => {
it('should remove the @ symbol', () => {
expect(normalizeCodeOwner('@yoda')).toBe('yoda');
});
it('should remove org from org/team format', () => {
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
});
it('should return username from email format', () => {
expect(normalizeCodeOwner('foo@acme.com')).toBe('foo');
});
it.each([['acme/foo'], ['dacme/foo']])(
'should return string everything else',
owner => {
expect(normalizeCodeOwner(owner)).toBe(owner);
},
);
});
@@ -0,0 +1,47 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * as codeowners from 'codeowners-utils';
import { CodeOwnersEntry } from 'codeowners-utils';
import { filter, get, head, pipe, reverse } from 'lodash/fp';
export function resolveCodeOwner(
contents: string,
pattern = '*',
): string | undefined {
const owners = codeowners.parse(contents);
return pipe(
filter((e: CodeOwnersEntry) => e.pattern === pattern),
reverse,
head,
get('owners'),
head,
normalizeCodeOwner,
)(owners);
}
export function normalizeCodeOwner(owner: string) {
if (owner.match(/^@.*\/.*/)) {
return owner.split('/')[1];
} else if (owner.match(/^@.*/)) {
return owner.substring(1);
} else if (owner.match(/^.*@.*\..*$/)) {
return owner.split('@')[0];
}
return owner;
}
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const CODEOWNERS = 'CODEOWNERS';
export const scmCodeOwnersPaths: Record<string, string[]> = {
// https://mibexsoftware.atlassian.net/wiki/spaces/CODEOWNERS/pages/222822413/Usage
bitbucket: [CODEOWNERS, `.bitbucket/${CODEOWNERS}`],
// https://docs.gitlab.com/ee/user/project/code_owners.html#how-to-set-up-code-owners
gitlab: [CODEOWNERS, `.gitlab/${CODEOWNERS}`, `docs/${CODEOWNERS}`],
// https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-file-location
github: [CODEOWNERS, `.github/${CODEOWNERS}`, `docs/${CODEOWNERS}`],
};
@@ -309,7 +309,7 @@ export class CatalogBuilder {
LdapOrgReaderProcessor.fromConfig(config, { logger }),
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader, logger }),
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
new LocationEntityProcessor({ integrations }),
new AnnotateLocationEntityProcessor({ integrations }),
);