Recently, I found myself needing to incorporate a CODEOWNERS-file to multiple repositories within our Github organization. The objective was to automatically reviewers for pull-requests. And codeowners are a perfect tool for this. This way every colleague in our company can join our Github organization and get write-access to the repositories (so they can create branches) and finally create a pull requests without needing to fork it to their personal accounts.

But I absolutely wanted to avoid creating the codeowners-file by either:

  • Cloning all repositories before adding, committing, and pushing the files (much less through a Pull Request)
  • Adding the files through the Github UI (meaning clicking on “Add file”, “Create new File”, inserting the contents, and committing).

Fortunately you can also do this via Github’s REST-API! But it was harder than I thought!

I did not want to add individual persons to the codeowners-file - if the person leaves the company or does not want to be a codeowner anymore, I’d have to change the codeowners-file in all repositories again. Teams, in this context, are hugely beneficial. Github allows the creation of teams in your organization and you can people to them. You can then use these teams in the codeowners-file. This way, deleting a person from a team removes them as a codeowner across all repositories where the team is used.

After creating the teams and adding people to them (I did this by hand), I needed to give the teams access to the repositories. This I did not by hand but via the API.

The command to add a team to a repository looks like this:

> gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" -f permission=push -X PUT /orgs/telekom-mms/teams/terraform-maintainers/repos/telekom-mms/examplerepo; done

This grants the team terraform-maintainers access to the examplerepo-repository and grants them write-access (with -f permission=push).

Now if I want to do this for all terraform-repositories in our organization, I use a simple for-loop on the command-line, thereby searching for all repositories with gh repo list and then grepping only the repositories with terraform in their name:

> for i in $(gh repo list telekom-mms --json name -q .[].name -L 100 | grep terraform); do gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" -f permission=push -X PUT /orgs/telekom-mms/teams/terraform-maintainers/repos/telekom-mms/$i; done

Once the teams are added, I can then add them to the codeowners-file. To do this via the API, I basically have to PUT content into the repository (see the docs for more information). Since this is git after all, I need to do this in a commit. And a commit needs:

  • a commit message - this is done by adding the form-field message with the message as a value.
  • a committer - this way trickier to achieve. I need to put a json-string with a name and an email-address as
  • the content of the file - this is the content of the file as a base64-encoded string:

    cat CODEOWNERS | base64 KiBAdGVsZWtvbS1tbXMvdGVycmFmb3JtLWFkbWlucwo=

The final call to the API looks like this:

> gh api --method PUT repos/telekom-mms/examplerepo/contents/CODEOWNERS -F message="add codeowners" -F committer:='{"name:"Sebastian Gumprich",email:"sebastian.gumprich@telekom.de"}' -F content="KiBAdGVsZWtvbS1tbXMvdGVycmFmb3JtLW1haW50YWluZXJzCg=="; done

Do this in a loop and you’re good to go.

But of course you will be making an error when updating the file (at least I did) and you will then have to update the file. If you want to update a file using a commit, you cannot use the above command as-is. You need to add the blob-SHA of the file you want to replace.

How do you get it? By querying the API again (there’s a way to do this without using the API but I did not do this since the API-way was easier):

> gh api repos/telekom-mms/examplerepo/contents/CODEOWNERS -q .sha
d79b5f4ecfd52ef6ecea0a71744f6ce94a2522da

Now add this SHA to the API-call and then you can update the file:

> gh api --method PUT repos/telekom-mms/examplerepo/contents/CODEOWNERS -F message="update codeowners" -F committer:='{"name:"Sebastian Gumprich",email:"sebastian.gumprich@telekom.de"}' -F content="KiBAdGVsZWtvbS1tbXMvdGVycmFmb3JtLW1haW50YWluZXJzCg==" -F sha="d79b5f4ecfd52ef6ecea0a71744f6ce94a2522da"

With the correct owners in place, now the last step was to set the branch protection rule that defines that codeowners need to do reviews. This took me the longest time since from the documentation alone I couldn’t create a working request.

So I tried to use the existing branch protection of a repository (easily gettable by running gh api repos/telekom-mms/examplerepo/branches/main/protection), but getting the following code inside a working curl-request and then looping over said curl-request proved to be impossible:

{
  "url": "https://api.github.com/repos/telekom-mms/examplerepo/branches/main/protection",
  "required_pull_request_reviews": {
    "url": "https://api.github.com/repos/telekom-mms/examplerepo/branches/main/protection/required_pull_request_reviews",
    "dismiss_stale_reviews": false,
    "require_code_owner_reviews": true,
    "require_last_push_approval": true,
    "required_approving_review_count": 1,
    "bypass_pull_request_allowances": {
      "users": [],
      "teams": [],
      "apps": [
        {
          "id": 2740,
          "slug": "renovate",
          "node_id": "MDM6QXBwMjc0MA==",
          "owner": {
            "login": "renovatebot",
            "id": 38656520,
            "node_id": "MDEyOk9yZ2FuaXphdGlvbjM4NjU2NTIw",
            "avatar_url": "https://avatars.githubusercontent.com/u/38656520?v=4",
            "gravatar_id": "",
            "url": "https://api.github.com/users/renovatebot",
            "html_url": "https://github.com/renovatebot",
            "followers_url": "https://api.github.com/users/renovatebot/followers",
            "following_url": "https://api.github.com/users/renovatebot/following{/other_user}",
            "gists_url": "https://api.github.com/users/renovatebot/gists{/gist_id}",
            "starred_url": "https://api.github.com/users/renovatebot/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/renovatebot/subscriptions",
            "organizations_url": "https://api.github.com/users/renovatebot/orgs",
            "repos_url": "https://api.github.com/users/renovatebot/repos",
            "events_url": "https://api.github.com/users/renovatebot/events{/privacy}",
            "received_events_url": "https://api.github.com/users/renovatebot/received_events",
            "type": "Organization",
            "site_admin": false
          },
          "name": "Renovate",
          "description": "[omitted for brevity]",
          "external_url": "https://www.mend.io/free-developer-tools/renovate/",
          "html_url": "https://github.com/apps/renovate",
          "created_at": "2017-06-02T07:04:12Z",
          "updated_at": "2023-11-06T09:25:36Z",
          "permissions": {
            "administration": "read",
            "checks": "write",
            "contents": "write",
            "emails": "read",
            "issues": "write",
            "members": "read",
            "metadata": "read",
            "packages": "read",
            "pull_requests": "write",
            "statuses": "write",
            "vulnerability_alerts": "read",
            "workflows": "write"
          },
          "events": [
            "issues",
            "pull_request",
            "push",
            "repository"
          ]
        }
      ]
    }
  },
  "required_signatures": {
    "url": "https://api.github.com/repos/telekom-mms/examplerepo/branches/main/protection/required_signatures",
    "enabled": false
  },
  "enforce_admins": {
    "url": "https://api.github.com/repos/telekom-mms/examplerepo/branches/main/protection/enforce_admins",
    "enabled": false
  },
  "required_linear_history": {
    "enabled": false
  },
  "allow_force_pushes": {
    "enabled": false
  },
  "allow_deletions": {
    "enabled": false
  },
  "block_creations": {
    "enabled": false
  },
  "required_conversation_resolution": {
    "enabled": false
  },
  "lock_branch": {
    "enabled": false
  },
  "allow_fork_syncing": {
    "enabled": false
  }
}

But I started using the above response and tried to cut it down to a minimal request that works. This took quite some time, but here’s the final result:

> curl -X PUT -H "Authorization: token $GITHUB_TOKEN" -H  "Accept: application/vnd.github.v3+json" https://api.github.com/repos/telekom-mms/examplerepo/branches/main/protection -d '{
  "required_pull_request_reviews": {
    "dismiss_stale_reviews": false,
    "require_code_owner_reviews": true,
    "require_last_push_approval": true,
    "required_approving_review_count": 1,
    "bypass_pull_request_allowances": {
      "apps": ["branch-protection-as-code"]
    }
  },
  "enforce_admins": false,
  "restrictions": null,
  "required_status_checks": null
}'; done

Looping over this short piece of code worked. I now am able to manage our repositories at scale (now someone tell me the existing solution to do this).



Related posts: