Skip to content

Commit

Permalink
ci: refresh MAINTAINERS.yaml for each CODEOWNERS file change
Browse files Browse the repository at this point in the history
  • Loading branch information
mszostok committed Jul 19, 2024
1 parent e27f9f1 commit 98ed0e2
Show file tree
Hide file tree
Showing 13 changed files with 4,717 additions and 2 deletions.
22 changes: 20 additions & 2 deletions .github/workflows/global-replicator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ jobs:
uses: derberg/manage-files-in-multiple-repositories@beecbe897cf5ed7f3de5a791a3f2d70102fe7c25
with:
github_token: ${{ secrets.GH_TOKEN }}
patterns_to_include: .github/workflows/scripts,.github/workflows/automerge-for-humans-add-ready-to-merge-or-do-not-merge-label.yml,.github/workflows/add-good-first-issue-labels.yml,.github/workflows/automerge-for-humans-merging.yml,.github/workflows/automerge-for-humans-remove-ready-to-merge-label-on-edit.yml,.github/workflows/automerge-orphans.yml,.github/workflows/automerge.yml,.github/workflows/autoupdate.yml,.github/workflows/help-command.yml,.github/workflows/issues-prs-notifications.yml,.github/workflows/lint-pr-title.yml,.github/workflows/notify-tsc-members-mention.yml,.github/workflows/stale-issues-prs.yml,.github/workflows/welcome-first-time-contrib.yml,.github/workflows/release-announcements.yml,.github/workflows/bounty-program-commands.yml,.github/workflows/please-take-a-look-command.yml,.github/workflows/update-pr.yml
patterns_to_include: .github/workflows/scripts,.github/workflows/automerge-for-humans-add-ready-to-merge-or-do-not-merge-label.yml,.github/workflows/add-good-first-issue-labels.yml,.github/workflows/automerge-for-humans-merging.yml,.github/workflows/automerge-for-humans-remove-ready-to-merge-label-on-edit.yml,.github/workflows/automerge-orphans.yml,.github/workflows/automerge.yml,.github/workflows/autoupdate.yml,.github/workflows/help-command.yml,.github/workflows/issues-prs-notifications.yml,.github/workflows/lint-pr-title.yml,.github/workflows/notify-tsc-members-mention.yml,.github/workflows/stale-issues-prs.yml,.github/workflows/welcome-first-time-contrib.yml,.github/workflows/release-announcements.yml,.github/workflows/bounty-program-commands.yml,.github/workflows/please-take-a-look-command.yml,.github/workflows/update-pr.yml,.github/workflows/update-maintainers-trigger.yaml
patterns_to_ignore: .github/workflows/scripts/maintainers
committer_username: asyncapi-bot
committer_email: [email protected]
commit_message: "ci: update of files from global .github repo"
Expand Down Expand Up @@ -215,4 +216,21 @@ jobs:
committer_username: asyncapi-bot
committer_email: [email protected]
commit_message: "ci: update .prettierignore from global .github repo"
bot_branch_name: bot/update-files-from-global-repo
bot_branch_name: bot/update-files-from-global-repo

replicate_update_maintainers_workflow:
if: startsWith(github.repository, 'asyncapi/')
name: Replicate update-maintainers.yml workflow in the required repositories
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Replicating file
uses: derberg/manage-files-in-multiple-repositories@beecbe897cf5ed7f3de5a791a3f2d70102fe7c25
with:
github_token: ${{ secrets.GH_TOKEN }}
patterns_to_include: .github/workflows/update-maintainers.yaml,.github/workflows/scripts/maintainers
committer_username: asyncapi-bot
committer_email: [email protected]
commit_message: "ci: update update-maintainers.yml workflow from global .github repo"
bot_branch_name: bot/update-files-from-global-repo
37 changes: 37 additions & 0 deletions .github/workflows/scripts/maintainers/.eslintrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
env:
es6: true
node: true
commonjs: true
globals:
Atomics: readonly
SharedArrayBuffer: readonly

ignorePatterns:
- "!.*"
- "**/node_modules/.*"
- "**/dist/.*"
- "**/coverage/.*"
- "*.json"

parserOptions:
ecmaVersion: 2023
sourceType: module
requireConfigFile: false

extends:
- eslint:recommended
- plugin:github/recommended

rules:
{
"camelcase": "off",
"eslint-comments/no-use": "off",
"eslint-comments/no-unused-disable": "off",
"i18n-text/no-en": "off",
"import/no-commonjs": "off",
"import/no-namespace": "off",
"no-console": "off",
"no-unused-vars": "off",
"prettier/prettier": "error",
"semi": "off",
}
2 changes: 2 additions & 0 deletions .github/workflows/scripts/maintainers/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/node_modules
github.api.cache.json
2 changes: 2 additions & 0 deletions .github/workflows/scripts/maintainers/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
57 changes: 57 additions & 0 deletions .github/workflows/scripts/maintainers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Maintainers

The ["Update MAINTAINERS.yaml file"](../../update-maintainers.yaml) workflow, defined in the `community` repository performs a complete refresh by fetching all public repositories under AsyncAPI and their respective `CODEOWNERS` files.

## Workflow Execution

The "Update MAINTAINERS.yaml file" workflow is executed in the following scenarios:
1. **Weekly Schedule**: The workflow runs automatically every week.
2. **On-Demand**: When a `CODEOWNERS` file is changed in any repository under the AsyncAPI organization, the source repository triggers the workflow by emitting the `trigger-maintainers-update` event.
3. **Manual Trigger**: Users can manually trigger the workflow as needed.

### Workflow Steps

1. **Load Cache**: Attempt to read previously cached data from `github.api.cache.json` to optimize API calls.
2. **List All Repositories**: Retrieve a list of all public repositories under the AsyncAPI organization, skipping any repositories specified in the `IGNORED_REPOSITORIES` environment variable.
3. **Fetch `CODEOWNERS` Files**: For each repository:
- Detect the default branch (e.g., `main`, `master`, or a custom branch).
- Check for `CODEOWNERS` files in all valid locations as specified in the [GitHub documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location).
4. **Process `CODEOWNERS` Files**:
1. Extract GitHub usernames from each `CODEOWNERS` file, excluding emails, team names, and users specified by the `IGNORED_USERS` environment variable.
2. Retrieve profile information for each unique GitHub username.
3. Collect a fresh list of repositories currently owned by each GitHub user.
5. **Refresh Maintainers List**: Iterate through the existing maintainers list:
- Delete the entry if it:
- Refers to a deleted GitHub account.
- Was not found in any `CODEOWNERS` file across all repositories in the AsyncAPI organization.
- Otherwise, update **only** the `repos` property.
6. **Add New Maintainers**: Append any new maintainers not present in the previous list.
7. **Changes Summary**: Provide details on why a maintainer was removed or changed directly on the GitHub Action [summary page](https://github.blog/2022-05-09-supercharging-github-actions-with-job-summaries/).
8. **Save Cache**: Save retrieved data in `github.api.cache.json`.

## Job Details

- **Concurrency**: Ensures the workflow does not run multiple times concurrently to avoid conflicts.
- **Wait for PRs to be Merged**: The workflow waits for pending pull requests to be merged before execution. If the merged pull request addresses all necessary fixes, it prevents unnecessary executions.

## Handling Conflicts

Since the job performs a full refresh each time, resolving conflicts is straightforward:

1. Close the pull request with conflicts.
2. Navigate to the "Update MAINTAINERS.yaml file" workflow.
3. Trigger it manually by clicking "Run workflow".

## Caching Mechanism

Each execution of this action performs a full refresh through the following API calls:

```
ListRepos(AsyncAPI) # 1 call using GraphQL - not cached.
for each Repo
GetCodeownersFile(Repo) # N calls using REST API - all are cached. N refers to the number of public repositories under AsyncAPI.
for each codeowner
GetGitHubProfile(owner) # Y calls using REST API - all are cached. Y refers to unique GitHub users found across all CODEOWNERS files.
```

To avoid hitting the GitHub API rate limits, [conditional requests](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28#use-conditional-requests-if-appropriate) are used via `if-modified-since`. The API responses are saved into a `github.api.cache.json` file, which is later uploaded as a GitHub action cache item.
65 changes: 65 additions & 0 deletions .github/workflows/scripts/maintainers/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const fs = require("fs");
const core = require("@actions/core");

module.exports = {
fetchWithCache,
saveCache,
loadCache,
printAPICallsStats,
};

const CODEOWNERS_CACHE_PATH = "github.api.cache.json";

let cacheEntries = {};

let numberOfFullFetches = 0;
let numberOfCacheHits = 0;

async function loadCache() {
try {
cacheEntries = JSON.parse(fs.readFileSync(CODEOWNERS_CACHE_PATH, "utf8"));
} catch (error) {
core.warning(`Cache was not restored: ${error}`);
}
}

async function saveCache() {
fs.writeFileSync(CODEOWNERS_CACHE_PATH, JSON.stringify(cacheEntries));
}

async function fetchWithCache(cacheKey, fetchFn) {
const cachedResp = cacheEntries[cacheKey];

try {
const { data, headers } = await fetchFn({
headers: {
"if-modified-since": cachedResp?.lastModified ?? "",
},
});

cacheEntries[cacheKey] = {
// last modified header is more reliable than etag while executing calls on GitHub Action
lastModified: headers["last-modified"],
data,
};

numberOfFullFetches++;
return data;
} catch (error) {
if (error.status === 304) {
numberOfCacheHits++;
core.debug(`Returning cached data for ${cacheKey}`);
return cachedResp.data;
}
throw error;
}
}

function printAPICallsStats() {
core.startGroup("API calls statistic");
core.info(
`Number of API calls count against rate limit: ${numberOfFullFetches}`,
);
core.info(`Number of cache hits: ${numberOfCacheHits}`);
core.endGroup();
}
130 changes: 130 additions & 0 deletions .github/workflows/scripts/maintainers/gh_calls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const core = require("@actions/core");
const { fetchWithCache } = require("./cache");

module.exports = { getGitHubProfile, getAllCodeownersFiles, getRepositories };

async function getRepositories(github, owner, ignoredRepos) {
core.startGroup(
`Getting list of all public, non-archived repositories owned by ${owner}`,
);

const query = `
query repos($cursor: String, $owner: String!) {
organization(login: $owner) {
repositories(first: 100, after: $cursor, visibility: PUBLIC, isArchived: false) {
nodes {
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`;

const repos = [];
let cursor = null;

do {
const result = await github.graphql(query, { owner, cursor });
const { nodes, pageInfo } = result.organization.repositories;
repos.push(...nodes);

cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null;
} while (cursor);

core.debug(`List of repositories for ${owner}:`);
core.debug(JSON.stringify(repos, null, 2));
core.endGroup();

return repos.filter((repo) => !ignoredRepos.includes(repo.name));
}

async function getGitHubProfile(github, login) {
try {
const profile = await fetchWithCache(
`profile:${login}`,
async ({ headers }) => {
return github.rest.users.getByUsername({
username: login,
headers,
});
},
);

return removeNulls({
name: profile.name ?? login,
github: login,
twitter: profile.twitter_username,
availableForHire: profile.hireable,
isTscMember: false,
repos: [],
});
} catch (error) {
if (error.status === 404) {
return null;
}
throw error;
}
}

// Checks for all valid locations according to:
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
//
// Detect the repository default branch automatically.
async function getCodeownersFile(github, owner, repo) {
const paths = ["CODEOWNERS", "docs/CODEOWNERS", ".github/CODEOWNERS"];

for (const path of paths) {
try {
core.debug(
`[repo: ${owner}/${repo}]: Fetching CODEOWNERS file at ${path}`,
);
return await fetchWithCache(
`owners:${owner}/${repo}`,
async ({ headers }) => {
return github.rest.repos.getContent({
owner,
repo,
path,
headers: {
Accept: "application/vnd.github.raw+json",
...headers,
},
});
},
);
} catch (error) {
core.warning(
`[repo: ${owner}/${repo}]: Failed to fetch CODEOWNERS file at ${path}: ${error.message}`,
);
}
}

core.error(
`[repo: ${owner}/${repo}]: CODEOWNERS file not found in any of the expected locations.`,
);
return null;
}

async function getAllCodeownersFiles(github, owner, repos) {
core.startGroup(`Fetching CODEOWNERS files for ${repos.length} repositories`);
const files = [];
for (const repo of repos) {
const data = await getCodeownersFile(github, owner, repo.name);
if (!data) {
continue;
}
files.push({
repo: repo.name,
content: data,
});
}
core.endGroup();
return files;
}

function removeNulls(obj) {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null));
}
Loading

0 comments on commit 98ed0e2

Please sign in to comment.