From 4e8c3c010d4c54e99dce41be21480e6eed037e56 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:24:55 -0300 Subject: [PATCH 01/16] feat: model repository presentation metadata Signed-off-by: Vitor Mattos --- src/repository-classifier.ts | 3 +++ 1 file changed, 3 insertions(+) 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[]; }; From c91f49b6d0b0bb43b58231d81787f3b34c4f90c3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:25:14 -0300 Subject: [PATCH 02/16] feat: declare repository metadata policy Signed-off-by: Vitor Mattos --- src/config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/config.ts b/src/config.ts index d82fb73..4fb63c8 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 = config.repositories?.[repository.name]?.metadata; + return metadata ? structuredClone(metadata) : undefined; +} + export async function resolveRepositoryRulesets( config: GovernanceConfig, repository: RepositoryMetadata, From 253427d6fcfb9b74bace55ee137a7a4e50b7b23c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:25:17 -0300 Subject: [PATCH 03/16] feat: declare repository metadata policy Signed-off-by: Vitor Mattos --- src/config-validation.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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, From 5ad60e3eada47016c4b5f0f532cf27f1d4aaa474 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:25:40 -0300 Subject: [PATCH 04/16] feat: reconcile repository metadata through GitHub API Signed-off-by: Vitor Mattos --- src/github-client.ts | 46 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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, From 13b7e27f6308552032f46542b02ca40a825fc13a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:28:14 -0300 Subject: [PATCH 05/16] feat: prefer owner-qualified repository policy keys Signed-off-by: Vitor Mattos --- src/config.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4fb63c8..a7f71a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,7 +59,7 @@ export function resolveRepositoryMetadata( return undefined; } - const metadata = config.repositories?.[repository.name]?.metadata; + const metadata = repositorySelection(config, repository)?.metadata; return metadata ? structuredClone(metadata) : undefined; } @@ -76,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 ( @@ -94,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, From 1de3a339a2c97891fa2c280157a01f7ce49aabde Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:28:44 -0300 Subject: [PATCH 06/16] feat: plan and reconcile repository metadata Signed-off-by: Vitor Mattos --- src/governance.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/governance.ts b/src/governance.ts index 3eec353..eaee711 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,50 @@ 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); return { repository: `${repository.owner}/${repository.name}`, changes: planRepositoryRulesets(existing, desiredRulesets), + metadata: planRepositoryMetadata(repository, desiredMetadata), }; } @@ -49,6 +73,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 +84,7 @@ export async function planOrganization( client, repository, await resolveRulesets(repository), + await resolveMetadata(repository), ), ); } @@ -70,8 +96,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 +115,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 +130,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 +141,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, + }; +} From 009a969beb6145bcbef5800ec0bab8997d3a0172 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:29:00 -0300 Subject: [PATCH 07/16] feat: include repository metadata in governance plan Signed-off-by: Vitor Mattos --- src/cli-runner.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) 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', ); } From 8aad99684ed8c22fc648c105809483c525f71fef Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:29:26 -0300 Subject: [PATCH 08/16] test: cover additive repository metadata planning Signed-off-by: Vitor Mattos --- tests/governance.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/governance.test.ts b/tests/governance.test.ts index b48f40f..ff9766a 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'], + }, + }); + }); + }); From c8cb274895ef13409d51f5a849389bfd1eea0d4b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:29:37 -0300 Subject: [PATCH 09/16] test: validate repository metadata configuration Signed-off-by: Vitor Mattos --- tests/config-validation.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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', + ); + }); + }); From e8a37945ffa629cb0e0b5e86dc9e9cc39b820d23 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:29:53 -0300 Subject: [PATCH 10/16] test: cover repository metadata API writes Signed-off-by: Vitor Mattos --- tests/github-client.test.ts | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) 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); + }); + }); From f07b89cfa19aa732d6bff752e7f3b915d685309d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:31:10 -0300 Subject: [PATCH 11/16] config: declare repository presentation metadata Signed-off-by: Vitor Mattos --- governance.config.json | 83 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) 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" + ] + } } } } From 1c90a965216877f405083da9b15c705e1dbede48 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:31:24 -0300 Subject: [PATCH 12/16] docs: document repository metadata governance Signed-off-by: Vitor Mattos --- docs/configuration.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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 From a68581c769e346d1dc2b61d5b98ce4bdaf283102 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:31:44 -0300 Subject: [PATCH 13/16] test: cover owner-qualified metadata selection Signed-off-by: Vitor Mattos --- tests/config.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) 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'], + }); + }); +}); From 14e472d4a94410f76124b6fa7fb95cfad5e8474f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:35:29 -0300 Subject: [PATCH 14/16] fix: preserve exact optional metadata typing Signed-off-by: Vitor Mattos --- src/governance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/governance.ts b/src/governance.ts index eaee711..2b4d36d 100644 --- a/src/governance.ts +++ b/src/governance.ts @@ -62,10 +62,12 @@ export async function planRepository( ): 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: planRepositoryMetadata(repository, desiredMetadata), + ...(metadata === undefined ? {} : { metadata }), }; } From c7d8bd801d757b8538f88ff08e171f1bad41adf7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:35:33 -0300 Subject: [PATCH 15/16] test: update CLI governance metadata fixtures Signed-off-by: Vitor Mattos --- tests/cli-runner.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 () => ({ From d6a87df6b4c1c13287f341e1df4dd3ca15af5ccd Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:35:37 -0300 Subject: [PATCH 16/16] test: guard planned repository metadata result Signed-off-by: Vitor Mattos --- tests/governance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/governance.test.ts b/tests/governance.test.ts index ff9766a..f532c15 100644 --- a/tests/governance.test.ts +++ b/tests/governance.test.ts @@ -144,7 +144,7 @@ describe('planOrganization', () => { }), ); - expect(plans[0].metadata).toEqual({ + expect(plans[0]?.metadata).toEqual({ action: 'update', fields: ['description', 'topics'], current: {