Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 81 additions & 2 deletions governance.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
}
}
35 changes: 28 additions & 7 deletions src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {
loadGovernanceConfig,
resolveRepositoryMetadata,
resolveRepositoryRulesets,
type GovernanceConfig,
} from './config.ts';
Expand Down Expand Up @@ -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('/');
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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<ReturnType<typeof planOrganization>>,
): 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',
);
}

Expand Down
23 changes: 22 additions & 1 deletion src/config-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ConditionalGovernanceConfig,
GovernanceConfig,
RepositoryGovernanceConfig,
RepositoryPresentation,
} from './config.ts';

export function validateGovernanceConfig(value: unknown): GovernanceConfig {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand All @@ -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,
Expand Down
Loading
Loading