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
4 changes: 2 additions & 2 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Workflows should orchestrate shared workspace scripts. Business logic belongs in

Current workflows:
- `workflows/validate.yml`: runs the local validation suite on pull requests, `main`/`master` pushes, and manual dispatch. This uses `pnpm check`, including typecheck, validation, generated artifact checks, and committed smoke checks.
- `workflows/approved-issue-update.yml`: when an issue receives the `approved` label, parses the Issue Form, writes a stable contribution record, applies approved contributions, regenerates derived artifacts, validates the result, and opens a pull request.
- `workflows/approved-issue-update.yml`: when an issue receives the `approved` label, serializes approved-issue processing, syncs all open approved Issue Forms into stable contribution records, applies approved contributions, regenerates derived artifacts, validates the result, and creates or updates one aggregate pull request.
- `workflows/release.yml`: on `main`/`master` data/code pushes, tags, or manual dispatch, regenerates and validates data, builds `release/animeatlas.sqlite`, and publishes it as the GitHub Release asset.

The approved-issue workflow does not push directly to `main`. It creates an `automation/approved-issue-<number>` branch and PR after running the same workspace commands used locally. The generated PR body contains `Closes #<issue>` so the issue closes only after the PR is merged. When that PR is merged, the push to `main`/`master` triggers the SQLite release workflow.
The approved-issue workflow does not push directly to `main`. It updates the single `automation/approved-issues` branch and PR after running the same workspace commands used locally. The workflow uses one global concurrency group so approved issue updates do not run against `db/` and `generated/` concurrently. Each run re-syncs all open approved issues, so a later run can fill the aggregate PR even when several approved labels arrive close together. The generated PR body contains `Closes #<issue>` lines so issues close only after the PR is merged. When that PR is merged, the push to `main`/`master` triggers the SQLite release workflow.
57 changes: 38 additions & 19 deletions .github/workflows/approved-issue-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ on:
- labeled

permissions:
actions: write
contents: write
issues: write
pull-requests: write

concurrency:
group: approved-issue-${{ github.event.issue.number }}
group: approved-issue-update
cancel-in-progress: false

jobs:
update-from-approved-issue:
name: Update From Approved Issue
name: Update From Approved Issues
if: github.event.label.name == 'approved'
runs-on: ubuntu-latest
steps:
Expand All @@ -40,8 +41,13 @@ jobs:
- name: Build workspace
run: pnpm build

- name: Write approved contribution record
run: pnpm --filter @animeatlas/github-action start -- write-approved-contribution "${{ github.event_path }}"
- name: List approved issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh issue list --state open --label approved --limit 1000 --json number,url,body,author,updatedAt > approved-issues.json

- name: Sync approved contribution records
run: pnpm --filter @animeatlas/github-action start -- sync-approved-issues approved-issues.json --approved-by github-actions[bot]

- name: Apply approved contributions
run: pnpm cli -- contributions apply-approved --write
Expand All @@ -52,21 +58,34 @@ jobs:
- name: Validate result
run: pnpm check

- name: Create update pull request
- name: Write pull request body
run: |
{
echo "Applies all currently open AnimeAtlas issues with the approved label."
echo
echo "This PR is updated by the approved issue workflow after running:"
echo "- \`pnpm build\`"
echo "- \`pnpm --filter @animeatlas/github-action start -- sync-approved-issues\`"
echo "- \`pnpm cli -- contributions apply-approved --write\`"
echo "- \`pnpm generate\`"
echo "- \`pnpm check\`"
echo
echo "Included issues:"
jq -r '.[] | "- Closes #\(.number)"' approved-issues.json
} > approved-issues-pr-body.md

- name: Create or update aggregate pull request
id: cpr
uses: peter-evans/create-pull-request@v6
with:
branch: automation/approved-issue-${{ github.event.issue.number }}
commit-message: "Apply approved issue #${{ github.event.issue.number }}"
title: "Apply approved issue #${{ github.event.issue.number }}"
body: |
Applies approved AnimeAtlas data contribution from #${{ github.event.issue.number }}.

This PR was generated from the approved Issue Form workflow after running:
- `pnpm build`
- `pnpm --filter @animeatlas/github-action start -- write-approved-contribution`
- `pnpm cli -- contributions apply-approved --write`
- `pnpm generate`
- `pnpm check`

Closes #${{ github.event.issue.number }}
branch: automation/approved-issues
commit-message: "Apply approved AnimeAtlas issues"
title: "Apply approved AnimeAtlas issues"
body-path: approved-issues-pr-body.md
delete-branch: true

- name: Run pull request validation workflow
if: steps.cpr.outputs.pull-request-number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run validate.yml --ref automation/approved-issues
86 changes: 82 additions & 4 deletions apps/github-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ type IssueEvent = {
sender?: { login?: string };
};

type IssueListItem = {
number?: number;
url?: string;
body?: string | null;
author?: { login?: string };
updatedAt?: string;
};

type OperationType = "create_media" | "add_alias" | "add_provider_ref" | "correct_metadata";

type ContributionRecord = {
Expand All @@ -41,6 +49,7 @@ type WriteApprovedContributionOptions = {
outDir?: string;
force?: boolean;
root?: string;
approvedBy?: string;
};

type WriteApprovedContributionResult = {
Expand Down Expand Up @@ -169,6 +178,48 @@ export function approvedContributionFileName(contribution: ContributionRecord):
return `issue-${String(contribution.issue.number).padStart(6, "0")}.json`;
}

export function contributionFromIssueListItem(issue: IssueListItem, approvedBy = "approved-label"): ParseResult {
return contributionFromIssueEvent({
action: "labeled",
label: { name: "approved" },
issue: {
number: issue.number,
html_url: issue.url,
body: issue.body,
user: issue.author,
updated_at: issue.updatedAt
},
sender: { login: approvedBy }
});
}

export function syncApprovedIssueRecords(
issues: IssueListItem[],
options: WriteApprovedContributionOptions = {}
): Array<WriteApprovedContributionResult & { skipped_existing: boolean }> {
const root = options.root ?? findRepoRoot(process.cwd());
const outDir = resolveFromRoot(root, options.outDir ?? "source/contributions/approved");
const results: Array<WriteApprovedContributionResult & { skipped_existing: boolean }> = [];

for (const issue of issues) {
const result = contributionFromIssueListItem(issue, options.approvedBy);
if (!result.ok) {
throw new Error(`Issue #${issue.number ?? "<unknown>"} cannot be synced: ${result.errors.join("; ")}`);
}

const file = join(outDir, approvedContributionFileName(result.contribution));
if (existsSync(file) && !options.force) {
results.push({ file, written: false, contribution: result.contribution, skipped_existing: true });
continue;
}

const writeResult = writeApprovedContributionRecord(result.contribution, { ...options, root, outDir });
results.push({ ...writeResult, skipped_existing: false });
}

return results;
}

function operationFromFields(fields: Record<string, string>, errors: string[]): ContributionRecord["operation"] | undefined {
const type = fields.change_type;
if (!isOperationType(type)) {
Expand Down Expand Up @@ -352,17 +403,37 @@ function usage(): string {
return [
"Usage:",
" github-action parse-issue-event <github-event-path>",
" github-action write-approved-contribution <github-event-path> [--out-dir source/contributions/approved] [--force]"
" github-action write-approved-contribution <github-event-path> [--out-dir source/contributions/approved] [--force]",
" github-action sync-approved-issues <issues-json-path> [--out-dir source/contributions/approved] [--force] [--approved-by login]"
].join("\n");
}

async function main(argv: string[]): Promise<void> {
const [command, eventPath, ...rest] = argv;
if (!command || !eventPath) {
const [command, inputPath, ...rest] = argv;
if (!command || !inputPath) {
throw new CliUsageError(usage());
}

const event = JSON.parse(readFileSync(eventPath, "utf8")) as IssueEvent;
if (command === "sync-approved-issues") {
const options = parseWriteOptions(rest);
const issues = JSON.parse(readFileSync(inputPath, "utf8")) as IssueListItem[];
if (!Array.isArray(issues)) {
throw new CliUsageError("sync-approved-issues expects a JSON array from gh issue list.");
}
const results = syncApprovedIssueRecords(issues, options);
process.stdout.write(stableStringify({
schema: "approved-issue-sync-result/v1",
summary: {
issues: issues.length,
written: results.filter((result) => result.written).length,
skipped_existing: results.filter((result) => result.skipped_existing).length
},
results
}));
return;
}

const event = JSON.parse(readFileSync(inputPath, "utf8")) as IssueEvent;
const result = contributionFromIssueEvent(event);
if (!result.ok) {
for (const error of result.errors) {
Expand Down Expand Up @@ -403,6 +474,13 @@ function parseWriteOptions(args: string[]): WriteApprovedContributionOptions {
index += 1;
} else if (arg === "--force") {
options.force = true;
} else if (arg === "--approved-by") {
const value = args[index + 1];
if (!value) {
throw new CliUsageError("--approved-by requires a login value.");
}
options.approvedBy = value;
index += 1;
} else {
throw new CliUsageError(`Unknown option: ${arg}`);
}
Expand Down
Loading