diff --git a/docs/configuration.md b/docs/configuration.md index 1f4d028..964cd44 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,6 +91,33 @@ Add or replace policy selection for a repository by repository name. Repository-specific selection is useful when one repository has stronger CI or review requirements than the organization default. + +Repository entries may use either the repository name or the fully qualified +`owner/repository` form. The fully qualified form takes precedence and should +be used when one configuration is shared across organizations or repository +names may collide. + +Repository entries may also declare presentation metadata: + +```json +{ + "repositories": { + "ExampleOrg/project": { + "metadata": { + "description": "Example project", + "homepage": "https://example.test", + "topics": ["hacktoberfest", "automation"] + } + } + } +} +``` + +Configured `description` and `homepage` are exact desired values. Configured +topics are a required minimum set: governance adds missing topics but preserves +other existing topics. This prevents governance from deleting useful discovery +metadata maintained by a project. + ### `conditions` Apply generic behavior based on repository properties. The currently supported diff --git a/governance.config.json b/governance.config.json index 45087e5..f582bd7 100644 --- a/governance.config.json +++ b/governance.config.json @@ -104,10 +104,89 @@ } ], "repositories": { - "github-governance": { + "LibreCodeCoop/github-governance": { "policies": [ "governance-ci" - ] + ], + "metadata": { + "description": "Declarative, testable GitHub repository governance and ruleset automation for organizations.", + "topics": [ + "hacktoberfest", + "github", + "governance", + "github-actions", + "automation", + "rulesets", + "security", + "librecode" + ] + } + }, + "LibreCodeCoop/release-tool": { + "metadata": { + "description": "Production-grade PHP CLI and PHAR for planning and automating reproducible software releases.", + "topics": [ + "hacktoberfest", + "php", + "cli", + "phar", + "release-automation", + "github-actions", + "nextcloud", + "nextcloud-app", + "semantic-versioning", + "changelog", + "keep-a-changelog", + "librecode" + ] + } + }, + "LibreCodeCoop/github-workflows": { + "metadata": { + "description": "Reusable, testable GitHub workflows for LibreCode projects and downstream integrations.", + "topics": [ + "hacktoberfest", + "github-actions", + "github-workflows", + "automation", + "ci", + "reusable-workflows", + "nextcloud", + "librecode" + ] + } + }, + "LibreCodeCoop/.github": { + "metadata": { + "description": "Shared GitHub organization profile, community files and workflow catalog for LibreCode Coop.", + "homepage": "https://librecode.coop/", + "topics": [ + "hacktoberfest", + "github", + "organization", + "github-actions", + "community", + "librecode" + ] + } + }, + "LibreSign/libresign": { + "metadata": { + "topics": [ + "hacktoberfest" + ] + } + }, + "LibreSign/documentation": { + "metadata": { + "description": "Source for LibreSign public documentation.", + "homepage": "https://docs.libresign.coop", + "topics": [ + "hacktoberfest", + "documentation", + "libresign" + ] + } } } } diff --git a/src/cli-runner.ts b/src/cli-runner.ts index dd60c8e..44fed4f 100644 --- a/src/cli-runner.ts +++ b/src/cli-runner.ts @@ -3,6 +3,7 @@ import { loadGovernanceConfig, + resolveRepositoryMetadata, resolveRepositoryRulesets, type GovernanceConfig, } from './config.ts'; @@ -57,7 +58,19 @@ export async function runCli( name: string; visibility: 'public' | 'private' | 'internal'; archived: boolean; + description: string | null; + homepage: string | null; + topics: string[]; }) => resolveRepositoryRulesets(config, repository, client); + const resolveMetadata = (repository: { + owner: string; + name: string; + visibility: 'public' | 'private' | 'internal'; + archived: boolean; + description: string | null; + homepage: string | null; + topics: string[]; + }) => resolveRepositoryMetadata(config, repository); if (repositoryArgument) { const [owner, repository, ...extra] = repositoryArgument.split('/'); @@ -68,17 +81,18 @@ export async function runCli( const metadata = await client.getRepository(owner, repository); const desiredRulesets = await resolveRulesets(metadata); + const desiredMetadata = resolveMetadata(metadata); const plan = apply - ? await syncRepository(client, metadata, desiredRulesets) - : await planRepository(client, metadata, desiredRulesets); + ? await syncRepository(client, metadata, desiredRulesets, desiredMetadata) + : await planRepository(client, metadata, desiredRulesets, desiredMetadata); writePlans([plan], apply, output); return !apply && hasDrift([plan]) ? 1 : 0; } const plans = apply - ? await syncOrganization(client, organization!, resolveRulesets) - : await planOrganization(client, organization!, resolveRulesets); + ? await syncOrganization(client, organization!, resolveRulesets, resolveMetadata) + : await planOrganization(client, organization!, resolveRulesets, resolveMetadata); writePlans(plans, apply, output); return !apply && hasDrift(plans) ? 1 : 0; @@ -94,7 +108,9 @@ function writePlans( (change) => change.action !== 'unchanged', ); - if (changes.length === 0) { + const metadataDrift = plan.metadata?.action === 'update'; + + if (changes.length === 0 && !metadataDrift) { output.log(`OK ${plan.repository}`); continue; } @@ -103,14 +119,19 @@ function writePlans( for (const change of changes) { output.log(` - ${change.action}: ${change.desired.name}`); } + if (metadataDrift) { + output.log(` - update metadata: ${plan.metadata!.fields.join(', ')}`); + } } } function hasDrift( plans: Awaited>, ): boolean { - return plans.some((plan) => - plan.changes.some((change) => change.action !== 'unchanged'), + return plans.some( + (plan) => + plan.changes.some((change) => change.action !== 'unchanged') || + plan.metadata?.action === 'update', ); } diff --git a/src/config-validation.ts b/src/config-validation.ts index ffcd266..67f9e30 100644 --- a/src/config-validation.ts +++ b/src/config-validation.ts @@ -10,6 +10,7 @@ import type { ConditionalGovernanceConfig, GovernanceConfig, RepositoryGovernanceConfig, + RepositoryPresentation, } from './config.ts'; export function validateGovernanceConfig(value: unknown): GovernanceConfig { @@ -57,7 +58,7 @@ function validateSelection( path: string, ): RepositoryGovernanceConfig { const record = expectRecord(value, path); - rejectUnknownKeys(record, path, ['policies', 'rulesets']); + rejectUnknownKeys(record, path, ['policies', 'rulesets', 'metadata']); const selection: RepositoryGovernanceConfig = {}; if ('policies' in record) { @@ -68,9 +69,29 @@ function validateSelection( (ruleset, index) => validateRuleset(ruleset, `${path}.rulesets[${index}]`), ); } + if ('metadata' in record) { + selection.metadata = validateMetadata(record.metadata, `${path}.metadata`); + } return selection; } +function validateMetadata(value: unknown, path: string): RepositoryPresentation { + const record = expectRecord(value, path); + rejectUnknownKeys(record, path, ['description', 'homepage', 'topics']); + + const metadata: RepositoryPresentation = {}; + if ('description' in record) { + metadata.description = expectNonEmptyString(record.description, `${path}.description`); + } + if ('homepage' in record) { + metadata.homepage = expectNonEmptyString(record.homepage, `${path}.homepage`); + } + if ('topics' in record) { + metadata.topics = expectStringArray(record.topics, `${path}.topics`); + } + return metadata; +} + function validateCondition( value: unknown, path: string, diff --git a/src/config.ts b/src/config.ts index d82fb73..a7f71a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,9 +12,16 @@ import type { } from './repository-classifier.ts'; import { validateGovernanceConfig } from './config-validation.ts'; +export type RepositoryPresentation = { + description?: string; + homepage?: string; + topics?: string[]; +}; + export type RepositoryGovernanceConfig = { policies?: string[]; rulesets?: RepositoryRuleset[]; + metadata?: RepositoryPresentation; }; export type ConditionalGovernanceConfig = { @@ -44,6 +51,18 @@ export async function loadGovernanceConfig( return validateGovernanceConfig(parsed); } +export function resolveRepositoryMetadata( + config: GovernanceConfig, + repository: RepositoryMetadata, +): RepositoryPresentation | undefined { + if (repository.visibility !== 'public' || repository.archived) { + return undefined; + } + + const metadata = repositorySelection(config, repository)?.metadata; + return metadata ? structuredClone(metadata) : undefined; +} + export async function resolveRepositoryRulesets( config: GovernanceConfig, repository: RepositoryMetadata, @@ -57,7 +76,7 @@ export async function resolveRepositoryRulesets( applySelection(config, config.defaults, resolved); - applySelection(config, config.repositories?.[repository.name], resolved); + applySelection(config, repositorySelection(config, repository), resolved); for (const condition of config.conditions ?? []) { if ( @@ -75,6 +94,14 @@ export async function resolveRepositoryRulesets( return [...resolved.values()].map((ruleset) => structuredClone(ruleset)); } +function repositorySelection( + config: GovernanceConfig, + repository: RepositoryMetadata, +): RepositoryGovernanceConfig | undefined { + return config.repositories?.[`${repository.owner}/${repository.name}`] + ?? config.repositories?.[repository.name]; +} + function applySelection( config: GovernanceConfig, selection: RepositoryGovernanceConfig | ConditionalGovernanceConfig | undefined, diff --git a/src/github-client.ts b/src/github-client.ts index ffdcd97..4d5df6b 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -21,6 +21,9 @@ type GitHubRepository = { name?: unknown; archived?: unknown; visibility?: unknown; + description?: unknown; + homepage?: unknown; + topics?: unknown; owner?: { login?: unknown; }; @@ -60,6 +63,9 @@ export class GitHubClient const login = data.owner?.login; const visibility = data.visibility; const archived = data.archived; + const description = data.description; + const homepage = data.homepage; + const topics = data.topics; if ( login !== owner || @@ -69,7 +75,10 @@ export class GitHubClient visibility === 'private' || visibility === 'internal' ) || - typeof archived !== 'boolean' + typeof archived !== 'boolean' || + !(typeof description === 'string' || description === null || description === undefined) || + !(typeof homepage === 'string' || homepage === null || homepage === undefined) || + !(topics === undefined || (Array.isArray(topics) && topics.every((topic) => typeof topic === 'string'))) ) { throw new Error(`Invalid repository response for ${owner}/${repository}`); } @@ -79,6 +88,9 @@ export class GitHubClient name, visibility, archived, + description: typeof description === 'string' ? description : null, + homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null, + topics: Array.isArray(topics) ? topics as string[] : [], }; } @@ -100,6 +112,9 @@ export class GitHubClient const name = repository.name; const visibility = repository.visibility; const archived = repository.archived; + const description = repository.description; + const homepage = repository.homepage; + const topics = repository.topics; if ( owner === organization && @@ -114,6 +129,9 @@ export class GitHubClient name, visibility, archived, + description: typeof description === 'string' ? description : null, + homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null, + topics: Array.isArray(topics) && topics.every((topic) => typeof topic === 'string') ? topics as string[] : [], }); } } @@ -129,6 +147,32 @@ export class GitHubClient ); } + async updateRepositoryMetadata( + owner: string, + repository: string, + metadata: { description?: string; homepage?: string; topics?: string[] }, + ): Promise { + const body: Record = {}; + if (metadata.description !== undefined) { + body.description = metadata.description; + } + if (metadata.homepage !== undefined) { + body.homepage = metadata.homepage; + } + if (Object.keys(body).length > 0) { + await this.requestJson( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`, + { method: 'PATCH', body: JSON.stringify(body) }, + ); + } + if (metadata.topics !== undefined) { + await this.requestJson( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/topics`, + { method: 'PUT', body: JSON.stringify({ names: metadata.topics }) }, + ); + } + } + async exists( owner: string, repository: string, diff --git a/src/governance.ts b/src/governance.ts index 3eec353..2b4d36d 100644 --- a/src/governance.ts +++ b/src/governance.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and contributors // SPDX-License-Identifier: AGPL-3.0-or-later +import type { RepositoryPresentation } from './config.ts'; import { planRepositoryRulesets, reconcileRepositoryRulesets, @@ -21,27 +22,52 @@ export interface GovernanceClient repository: string, ): Promise; listManagedRepositories(organization: string): Promise; + updateRepositoryMetadata( + owner: string, + repository: string, + metadata: RepositoryPresentation, + ): Promise; } +export type RepositoryMetadataChange = { + action: 'unchanged' | 'update'; + fields: string[]; + current: { + description: string | null; + homepage: string | null; + topics: string[]; + }; + desired: RepositoryPresentation; +}; + export type RepositoryPlan = { repository: string; changes: RulesetChange[]; + metadata?: RepositoryMetadataChange; }; export type RepositoryPolicyResolver = ( repository: RepositoryMetadata, ) => Promise | RepositoryRuleset[]; +export type RepositoryMetadataResolver = ( + repository: RepositoryMetadata, +) => Promise | RepositoryPresentation | undefined; + export async function planRepository( client: GovernanceClient, repository: RepositoryMetadata, desiredRulesets: RepositoryRuleset[] = [], + desiredMetadata?: RepositoryPresentation, ): Promise { const existing = await client.list(repository.owner, repository.name); + const metadata = planRepositoryMetadata(repository, desiredMetadata); + return { repository: `${repository.owner}/${repository.name}`, changes: planRepositoryRulesets(existing, desiredRulesets), + ...(metadata === undefined ? {} : { metadata }), }; } @@ -49,6 +75,7 @@ export async function planOrganization( client: GovernanceClient, organization: string, resolveRulesets: RepositoryPolicyResolver = () => [], + resolveMetadata: RepositoryMetadataResolver = () => undefined, ): Promise { const repositories = await client.listManagedRepositories(organization); const plans: RepositoryPlan[] = []; @@ -59,6 +86,7 @@ export async function planOrganization( client, repository, await resolveRulesets(repository), + await resolveMetadata(repository), ), ); } @@ -70,8 +98,14 @@ export async function syncRepository( client: GovernanceClient, repository: RepositoryMetadata, desiredRulesets: RepositoryRuleset[] = [], + desiredMetadata?: RepositoryPresentation, ): Promise { - const plan = await planRepository(client, repository, desiredRulesets); + const plan = await planRepository( + client, + repository, + desiredRulesets, + desiredMetadata, + ); const desired = plan.changes.flatMap((change) => change.action === 'unchanged' ? [change.current] : [change.desired], ); @@ -83,6 +117,14 @@ export async function syncRepository( desired, ); + if (plan.metadata?.action === 'update') { + await client.updateRepositoryMetadata( + repository.owner, + repository.name, + plan.metadata.desired, + ); + } + return plan; } @@ -90,6 +132,7 @@ export async function syncOrganization( client: GovernanceClient, organization: string, resolveRulesets: RepositoryPolicyResolver = () => [], + resolveMetadata: RepositoryMetadataResolver = () => undefined, ): Promise { const repositories = await client.listManagedRepositories(organization); const plans: RepositoryPlan[] = []; @@ -100,9 +143,57 @@ export async function syncOrganization( client, repository, await resolveRulesets(repository), + await resolveMetadata(repository), ), ); } return plans; } + +function planRepositoryMetadata( + repository: RepositoryMetadata, + requested: RepositoryPresentation | undefined, +): RepositoryMetadataChange | undefined { + if (requested === undefined) { + return undefined; + } + + const fields: string[] = []; + const desired: RepositoryPresentation = {}; + + if (requested.description !== undefined) { + desired.description = requested.description; + if (requested.description !== repository.description) { + fields.push('description'); + } + } + + if (requested.homepage !== undefined) { + desired.homepage = requested.homepage; + if (requested.homepage !== repository.homepage) { + fields.push('homepage'); + } + } + + if (requested.topics !== undefined) { + const existing = new Set(repository.topics); + const merged = [...new Set([...repository.topics, ...requested.topics])].sort(); + desired.topics = merged; + + if (requested.topics.some((topic) => !existing.has(topic))) { + fields.push('topics'); + } + } + + return { + action: fields.length === 0 ? 'unchanged' : 'update', + fields, + current: { + description: repository.description, + homepage: repository.homepage, + topics: [...repository.topics], + }, + desired, + }; +} diff --git a/src/repository-classifier.ts b/src/repository-classifier.ts index 6ac9c94..c090c2b 100644 --- a/src/repository-classifier.ts +++ b/src/repository-classifier.ts @@ -10,4 +10,7 @@ export type RepositoryMetadata = { name: string; visibility: 'public' | 'private' | 'internal'; archived: boolean; + description: string | null; + homepage: string | null; + topics: string[]; }; diff --git a/tests/cli-runner.test.ts b/tests/cli-runner.test.ts index d81261b..3c88303 100644 --- a/tests/cli-runner.test.ts +++ b/tests/cli-runner.test.ts @@ -36,6 +36,9 @@ class FakeClient implements GovernanceClient { name: repository, visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }; } @@ -46,6 +49,9 @@ class FakeClient implements GovernanceClient { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, ]; } @@ -60,6 +66,7 @@ class FakeClient implements GovernanceClient { async create(): Promise {} async update(): Promise {} + async updateRepositoryMetadata(): Promise {} } const configLoader = async () => ({ diff --git a/tests/config-validation.test.ts b/tests/config-validation.test.ts index 55c052f..7cce7bd 100644 --- a/tests/config-validation.test.ts +++ b/tests/config-validation.test.ts @@ -37,6 +37,15 @@ const validConfig = { defaults: { policies: ['protected'], }, + repositories: { + 'ExampleOrg/project': { + metadata: { + description: 'Example project', + homepage: 'https://example.test', + topics: ['hacktoberfest', 'example'], + }, + }, + }, }; describe('validateGovernanceConfig', () => { @@ -83,4 +92,14 @@ describe('validateGovernanceConfig', () => { '$.policies.protected.bypass_actors[0].actor_type', ); }); + + it('rejects unsupported repository metadata keys', () => { + const config = structuredClone(validConfig) as any; + config.repositories['ExampleOrg/project'].metadata.typo = true; + + expect(() => validateGovernanceConfig(config)).toThrow( + '$.repositories.ExampleOrg/project.metadata.typo', + ); + }); + }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 3d305b8..e75192c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { describe, expect, it } from 'vitest'; -import { resolveRepositoryRulesets } from '../src/config.js'; +import { resolveRepositoryMetadata, resolveRepositoryRulesets } from '../src/config.js'; import type { GovernanceConfig } from '../src/config.js'; const protectedBranches = { @@ -53,6 +53,9 @@ describe('resolveRepositoryRulesets', () => { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => false }, ); @@ -68,6 +71,9 @@ describe('resolveRepositoryRulesets', () => { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => true }, ); @@ -113,6 +119,9 @@ describe('resolveRepositoryRulesets', () => { name: 'special', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => false }, ); @@ -132,6 +141,9 @@ describe('resolveRepositoryRulesets', () => { name: 'private', visibility: 'private', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => true }, ), @@ -145,6 +157,9 @@ describe('resolveRepositoryRulesets', () => { name: 'archive', visibility: 'public', archived: true, + description: null, + homepage: null, + topics: [], }, { exists: async () => true }, ), @@ -164,6 +179,9 @@ describe('resolveRepositoryRulesets', () => { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => false }, ), @@ -179,6 +197,9 @@ describe('resolveRepositoryRulesets', () => { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { exists: async () => { @@ -189,3 +210,37 @@ describe('resolveRepositoryRulesets', () => { ).rejects.toThrow('HTTP 403'); }); }); + + +describe('resolveRepositoryMetadata', () => { + it('prefers owner-qualified repository configuration over a name-only fallback', () => { + const repository = { + owner: 'ExampleOrg', + name: 'project', + visibility: 'public' as const, + archived: false, + description: null, + homepage: null, + topics: [], + }; + + expect( + resolveRepositoryMetadata( + { + repositories: { + project: { + metadata: { description: 'fallback' }, + }, + 'ExampleOrg/project': { + metadata: { description: 'qualified', topics: ['hacktoberfest'] }, + }, + }, + }, + repository, + ), + ).toEqual({ + description: 'qualified', + topics: ['hacktoberfest'], + }); + }); +}); diff --git a/tests/github-client.test.ts b/tests/github-client.test.ts index 47b7ed0..7aee692 100644 --- a/tests/github-client.test.ts +++ b/tests/github-client.test.ts @@ -34,6 +34,9 @@ describe('GitHubClient', () => { owner: { login: 'ExampleOrg' }, visibility: 'public', archived: false, + description: 'Project description', + homepage: 'https://example.test', + topics: ['existing-topic'], }), }, ]; @@ -51,6 +54,9 @@ describe('GitHubClient', () => { name: 'project', visibility: 'public', archived: false, + description: 'Project description', + homepage: 'https://example.test', + topics: ['existing-topic'], }); expect(requests).toHaveLength(0); }); @@ -66,24 +72,36 @@ describe('GitHubClient', () => { owner: { login: 'ExampleOrg' }, visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { name: 'archive', owner: { login: 'ExampleOrg' }, visibility: 'public', archived: true, + description: null, + homepage: null, + topics: [], }, { name: 'private', owner: { login: 'ExampleOrg' }, visibility: 'private', archived: false, + description: null, + homepage: null, + topics: [], }, { name: 'other', owner: { login: 'OtherOrg' }, visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, ], }), @@ -102,6 +120,9 @@ describe('GitHubClient', () => { name: 'project', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, ]); expect(requests).toHaveLength(0); @@ -218,4 +239,32 @@ describe('GitHubClient', () => { expect(requests).toHaveLength(0); }); + + it('updates repository description and preserves merged topics supplied by the planner', async () => { + const requests: ExpectedRequest[] = [ + { + url: 'https://api.github.test/repos/ExampleOrg/project', + method: 'PATCH', + response: Response.json({}), + }, + { + url: 'https://api.github.test/repos/ExampleOrg/project/topics', + method: 'PUT', + response: Response.json({ names: ['existing-topic', 'hacktoberfest'] }), + }, + ]; + const client = new GitHubClient( + 'token', + fakeFetch(requests), + 'https://api.github.test', + ); + + await client.updateRepositoryMetadata('ExampleOrg', 'project', { + description: 'Project description', + topics: ['existing-topic', 'hacktoberfest'], + }); + + expect(requests).toHaveLength(0); + }); + }); diff --git a/tests/governance.test.ts b/tests/governance.test.ts index b48f40f..f532c15 100644 --- a/tests/governance.test.ts +++ b/tests/governance.test.ts @@ -67,6 +67,10 @@ class FakeGovernanceClient async update(): Promise { throw new Error('not expected in planning'); } + + async updateRepositoryMetadata(): Promise { + throw new Error('not expected in planning'); + } } describe('planOrganization', () => { @@ -78,12 +82,18 @@ describe('planOrganization', () => { name: 'one', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, { owner: 'ExampleOrg', name: 'two', visibility: 'public', archived: false, + description: null, + homepage: null, + topics: [], }, ], new Map([ @@ -107,4 +117,46 @@ describe('planOrganization', () => { changes: [{ action: 'create' }], }); }); + + it('plans metadata drift without removing existing topics', async () => { + const client = new FakeGovernanceClient( + [ + { + owner: 'ExampleOrg', + name: 'project', + visibility: 'public', + archived: false, + description: null, + homepage: null, + topics: ['existing-topic'], + }, + ], + new Map(), + ); + + const plans = await planOrganization( + client, + 'ExampleOrg', + () => [], + () => ({ + description: 'Project description', + topics: ['hacktoberfest', 'existing-topic'], + }), + ); + + expect(plans[0]?.metadata).toEqual({ + action: 'update', + fields: ['description', 'topics'], + current: { + description: null, + homepage: null, + topics: ['existing-topic'], + }, + desired: { + description: 'Project description', + topics: ['existing-topic', 'hacktoberfest'], + }, + }); + }); + });