Code owners

Overview

GitHub and GitLab support CODEOWNERS files. This file specifies the users responsible for developing and maintaining software and documentation. This definition can apply to the entire repository, specific directories, or to individual files. To learn more:

Hugo can natively parse a CODEOWNERS file in your Git repository, and then retrieve user information directly from GitHub or GitLab.

Why is this important?

For both internal and open source documentation projects, knowing who works on what is helpful to both document authors and document consumers. Including names and photographs in the user interface makes the documentation more approachable, and personal, for document consumers.

How it works

On the right side of this page you will see a “Code owners” block. Hugo retrieves and caches the names, links, and images when the building this site.

To read the CODEOWNERS file, you must set enableGitInfo = true in your site configuration.

The CODEOWNERS file for this site contains the entry below, defining the users responsible for this page:

/content/articles/code-owners.md  @torvalds  @poettering

When you use the .CodeOwners method on .Page, Hugo determines the responsible users based on the file path of the current page, and returns a slice:

[
  "@torvalds",
  "@poettering"
]

This is a simple example. In a large project the CODEOWNERS file may contain overlapping entries for the entire project, specific sections of the project, and individual pages.

Now that we have the usernames, we use the resources.GetRemote function to call the GitHub or GitLab API to retrieve the information we need.

Shortcode

Use this “codeowners” shortcode to insert a block into your Markdown.

Arguments

service
(string) Optional. The service with which to resolve the CODEOWNERS data, either github or gitlab. The default value is github.
size
(string) Optional. The size, in pixels, at which to display the avatar. The default value is 60x60.

Examples

{{< codeowners >}}
{{< codeowners size="80x80" >}}
{{< codeowners service="gitlab" size="100x100" >}}

Source code

To style the resulting block, please see the Appearance section at the end of this article.

layouts/shortcodes/codeowners.html
{{- /* Last modified: 2023-12-27T10:41:47-08:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders the CODEOWNERS data from GitHub or GitLab for a given page.

References:

  - https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-codeowners
  - https://docs.gitlab.com/ee/user/project/code_owners.html

There are three types of code owner IDs in a CODEOWNERS file:

- User (@user)
- Group (@organization/group) (also known as a team)
- Email address (foo@example.org)

If the code owner ID is a user, retrieve the user's name, link, and avatar from
the service API. If unable to retrieve the user's information, assume this is a
non-existent user and display nothing.

If the code owner ID is a group or email address, display nothing.

Why not retrieve group information from the service API? That requires
authentication.

Why not display the email address? To prevent unplanned exposure of personal
information.

Errors encoutered while accessing the service API will generate warnings, not
errors. The build will continue.

@context {string} Inner The content between the opening and closing shortcode tags.
@context {string} InnerDeindent The content between the opening and closing shortcode tags with indentation removed.
@context {string} Name The file name of the shortcode template, excluding the extension.
@context {int} Ordinal The zero-based ordinal of the shortcode on the page, or within its parent shortcode.
@context {page} Page A reference to the page containing the shortcode.
@context {map} Params The parameters specified in the opening shortcode tag.
@context {hugolib.ShortcodeWithPage} Parent The context of the parent shortcode.
@context {text.Position} Position The position of the shortcode within the page content.

@method {any} Get Returns the parameter value for the given key (for named parameters) or position (for positional parameters).
@mathod {bool} IsNamedParams Returns true if the shortcode is called with named instead of positional parameters.
@method {maps.Scratch) Scratch Returns a writable Scratch to store and manipulate data.

@param {string} [Params.service=github] The service with which to resolve the CODEOWNERS data, either github or gitlab.
@param {string} [Params.size=60x60] The size, in pixels, at which to display the avatar.

@returns {template.html}

@example {{< codeowners >}}
@example {{< codeowners service="github" >}}
@example {{< codeowners size="80x80" >}}
*/}}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.114.0" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q shortcode requires Hugo v%s or later." .Name $minHugoVersion }}
{{- end }}

{{- /* Get context. */}}
{{- $name := .Name }}
{{- $ordinal := .Ordinal }}
{{- $position := .Position }}

{{- /* Define API endpoints. */}}
{{- $apis := dict
  "github" (dict
    "users" "https://api.github.com/users"
  )
  "gitlab" (dict
    "users" "https://gitlab.com/api/v4/users?username="
  )
}}

{{- /* Set default values. */}}
{{- $avatarFormat := "webp" }}
{{- $avatarSize := "60x60" }}
{{- $service := "github" }}

{{- /* Build a slice of valid services based on the API endpoints. */}}
{{- $validServices := slice }}
{{- range $k, $_ := $apis }}
  {{- $validServices = $validServices | append $k }}
{{- end }}

{{- /* Validate parameters. */}}
{{- with .Get "service" }}
  {{- $service = strings.ToLower . }}
  {{- if not (in $validServices $service) }}
    {{- errorf "The service specified when calling the %q shortcode is invalid. The valid options are %s. See %s" $name (delimit $validServices ", " " and ") $position }}
  {{- end }}
{{- end }}
{{- with .Get "size" }}
  {{- $avatarSize = . }}
{{- end }}

{{- /* Build slice of code owners. */}}
{{- $codeOwners := slice }}
{{- range $codeOwner := .Page.CodeOwners }}
  {{- $type := "" }}
  {{- if and (strings.HasPrefix $codeOwner "@") (strings.Contains $codeOwner "/") }}
    {{- $type = "group" }}
  {{- else if strings.HasPrefix $codeOwner "@" }}
    {{- $type = "user" }}
  {{- else if strings.Contains $codeOwner "@" }}
    {{- $type = "email" }}
  {{- end }}

  {{- $name := $codeOwner }}
  {{- $link := "" }}
  {{- $img := "" }}

  {{- if eq $type "user" }}
    {{- $slug := strings.TrimPrefix "@" $codeOwner }}
    {{- if eq $service "github" }}
      {{- with resources.GetRemote (printf "%s/%s" (index $apis $service "users") $slug) }}
        {{- with .Err }}
          {{- warnf "%s" . }}
        {{- else }}
          {{- $u := .Content | transform.Unmarshal }}
          {{- with $u.name }}
            {{- $name = . }}
          {{- end }}
          {{- with $u.html_url }}
            {{- $link = . }}
          {{- end }}
          {{- with $u.avatar_url }}
            {{- with resources.GetRemote . }}
              {{- with .Err }}
                {{- warnf "%s" }}
              {{- else }}
                {{- $img = .Fill (printf "%s %s" $avatarSize $avatarFormat) }}
              {{- end }}
            {{- else }}
              {{- warnf "The %q shortcode was unable to retrieve an image from %s for %s. See %s" $name $service $codeOwner $position }}
            {{- end }}
          {{- end }}
          {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name "link" $link "img" $img) }}
        {{- end }}
      {{- else }}
        {{- warnf "The %q shortcode was unable to retrieve information from %s for %s. See %s" $name $service $codeOwner $position }}
      {{- end }}
    {{- else if eq $service "gitlab" }}
      {{- with resources.GetRemote (printf "%s%s" (index $apis $service "users") $slug) }}
        {{- with .Err }}
          {{- errorf "%s" . }}
        {{- else }}
          {{- /* GitLab returns '[]' instead of nothing. */}}
          {{- if gt (len .Content) 2 }}
            {{- $u := index (.Content | transform.Unmarshal) 0 }}
            {{- with $u.name }}
              {{- $name = . }}
            {{- end }}
            {{- with $u.web_url }}
              {{- $link = . }}
            {{- end }}
            {{- with $u.avatar_url }}
              {{- with resources.GetRemote . }}
                {{- with .Err }}
                  {{- warnf "%s" }}
                {{- else }}
                  {{- $img = .Fill (printf "%s %s" $avatarSize $avatarFormat) }}
                {{- end }}
              {{- else }}
                {{- warnf "The $q shortocde was unable to retrieve an image from %s for %s. See %s" $name $service $codeOwner $position }}
              {{- end }}
            {{- end }}
            {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name "link" $link "img" $img) }}
          {{- end }}
        {{- end }}
      {{- else }}
        {{- errorf "The %q shortocde was unable to retrieve information from %s for %s. See %s" $name  $service $codeOwner $position }}
      {{- end }}
    {{- else }}
      {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }}
    {{- end }}
  {{- else if eq $type "group" }}
    {{- /* Uncomment to display groups. */}}
    {{- /* {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }} */}}
  {{- else if eq $type "email" }}
    {{- /* Uncomment to display email addresses. */}}
    {{- /* {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }} */}}
  {{- end }}
{{- end }}

{{- /* Render. */}}
{{- with $codeOwners }}
  <div class="codeowners-container">
    {{- range (sort $codeOwners "type" "desc") }}
      <div class="codeowner">
        {{- if and .link .img }}
          <div class="codeowner-image">
            <a href="{{ .link }}" rel="external">
              <img src="{{ .img.RelPermalink }}" width="{{ .img.Width }}" height="{{ .img.Height }}" alt="{{ .name }}">
            </a>
          </div>
          <div class="codeowner-link">
            <a href="{{ .link }}" rel="external">{{ .name }}</a>
          </div>
        {{- else if .link }}
          <div class="codeowner-link">
            <a href="{{ .link }}" rel="external">{{ .name }}</a>
          </div>
        {{- else }}
          <div class="codeowner-name">
            {{ .name }}
          </div>
        {{- end }}
      </div>
    {{- end }}
  </div>
{{- end -}}

Partial

Use this “codeowners” partial in your templates.

Arguments

page
(page) The page for which to retrieve the CODEOWNERS data. Optional if you do not specify service or size.
service
(string) Optional. The service with which to resolve the CODEOWNERS data, either github or gitlab. The default value is github.
size
(string) Optional. The size, in pixels, at which to display the avatar. The default value is 60x60.

Examples

{{ partial "codeowners.html" . }}
{{ partial "codeowners.html" (dict "page" .) }}
{{ partial "codeowners.html" (dict "page" . "service" "gitlab") }}
{{ partial "codeowners.html" (dict "page" . "service" "gitlab" "size" "80x80") }}

Source code

To style the resulting block, please see the Appearance section at the end of this article.

layouts/partials/codeowners.html
{{- /* Last modified: 2023-12-27T10:41:47-08:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders the CODEOWNERS data from GitHub or GitLab for a given page.

References:

  - https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-codeowners
  - https://docs.gitlab.com/ee/user/project/code_owners.html

There are three types of code owner IDs in a CODEOWNERS file:

- User (@user)
- Group (@organization/group) (also known as a team)
- Email address (foo@example.org)

If the code owner ID is a user, retrieve the user's name, link, and avatar from
the service API. If unable to retrieve the user's information, assume this is a
non-existent user and display nothing.

If the code owner ID is a group or email address, display nothing.

Why not retrieve group information from the service API? That requires
authentication.

Why not display the email address? To prevent unplanned exposure of personal
information.

Errors encoutered while accessing the service API will generate warnings, not
errors. The build will continue.

@context {page} page The page for which to retrieve the CODEOWNERS data.
@context {string} [service=github] The service with which to resolve the CODEOWNERS data, either github or gitlab.
@context {string} [size=60x60] The size, in pixels, at which to display the avatar.

@returns {template.HTML}

@example {{- partial "codeowners.html" . }}
@example {{- partial "codeowners.html" (dict "page" .) }}
@example {{- partial "codeowners.html" (dict "page" . "service" "github") }}
@example {{- partial "codeowners.html" (dict "page" . "service" "github" "size" "80x80") }}
*/}}

{{- /* Initialize. */}}
{{- $partialName := "codeowners" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.114.0" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}

{{- /* Define API endpoints. */}}
{{- $apis := dict
  "github" (dict
    "users" "https://api.github.com/users"
  )
  "gitlab" (dict
    "users" "https://gitlab.com/api/v4/users?username="
  )
}}

{{- /* Set default values. */}}
{{- $avatarFormat := "webp" }}
{{- $avatarSize := "60x60" }}
{{- $service := "github" }}

{{- /* Build a slice of valid services based on the API endpoints. */}}
{{- $validServices := slice }}
{{- range $k, $_ := $apis }}
  {{- $validServices = $validServices | append $k }}
{{- end }}

{{- /* Validate parameters. */}}
{{- $page := . }}

{{- if reflect.IsMap . }}
  {{- with .page }}
    {{- $page = . }}
  {{- else }}
    {{- errorf "When passing a map to the %q partial, one of the elements must be named 'page', and it must be set to the context of the current page." $partialName }}
  {{- end }}
  {{- with .service }}
    {{- $service = strings.ToLower . }}
    {{- if not (in $validServices $service) }}
      {{- errorf "The service specified in the map passed to the %q partial is invalid. The valid options are %s." $partialName (delimit $validServices ", " " and ") }}
    {{- end }}
  {{- end }}
  {{- with .size }}
    {{- $avatarSize = . }}
  {{- end }}
{{- end }}

{{- /* Build slice of code owners. */}}
{{- $codeOwners := slice }}
{{- range $codeOwner := $page.CodeOwners }}
  {{- $type := "" }}
  {{- if and (strings.HasPrefix $codeOwner "@") (strings.Contains $codeOwner "/") }}
    {{- $type = "group" }}
  {{- else if strings.HasPrefix $codeOwner "@" }}
    {{- $type = "user" }}
  {{- else if strings.Contains $codeOwner "@" }}
    {{- $type = "email" }}
  {{- end }}

  {{- $name := $codeOwner }}
  {{- $link := "" }}
  {{- $img := "" }}

  {{- if eq $type "user" }}
    {{- $slug := strings.TrimPrefix "@" $codeOwner }}
    {{- if eq $service "github" }}
      {{- with resources.GetRemote (printf "%s/%s" (index $apis $service "users") $slug) }}
        {{- with .Err }}
          {{- warnf "%s" . }}
        {{- else }}
          {{- $u := .Content | transform.Unmarshal }}
          {{- with $u.name }}
            {{- $name = . }}
          {{- end }}
          {{- with $u.html_url }}
            {{- $link = . }}
          {{- end }}
          {{- with $u.avatar_url }}
            {{- with resources.GetRemote . }}
              {{- with .Err }}
                {{- warnf "%s" }}
              {{- else }}
                {{- $img = .Fill (printf "%s %s" $avatarSize $avatarFormat) }}
              {{- end }}
            {{- else }}
              {{- warnf "The %q partial was unable to retrieve an image from %s for %s" $partialName $service $codeOwner }}
            {{- end }}
          {{- end }}
          {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name "link" $link "img" $img) }}
        {{- end }}
      {{- else }}
        {{- warnf "The %q partial was unable to retrieve information from %s for %s" $partialName $service $codeOwner }}
      {{- end }}
    {{- else if eq $service "gitlab" }}
      {{- with resources.GetRemote (printf "%s%s" (index $apis $service "users") $slug) }}
        {{- with .Err }}
          {{- errorf "%s" . }}
        {{- else }}
          {{- /* GitLab returns '[]' instead of nothing. */}}
          {{- if gt (len .Content) 2 }}
            {{- $u := index (.Content | transform.Unmarshal) 0 }}
            {{- with $u.name }}
              {{- $name = . }}
            {{- end }}
            {{- with $u.web_url }}
              {{- $link = . }}
            {{- end }}
            {{- with $u.avatar_url }}
              {{- with resources.GetRemote . }}
                {{- with .Err }}
                  {{- warnf "%s" }}
                {{- else }}
                  {{- $img = .Fill (printf "%s %s" $avatarSize $avatarFormat) }}
                {{- end }}
              {{- else }}
                {{- warnf "The %q partial was unable to retrieve an image from %s for %s" $partialName $service $codeOwner }}
              {{- end }}
            {{- end }}
            {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name "link" $link "img" $img) }}
          {{- end }}
        {{- end }}
      {{- else }}
        {{- errorf "The %q partial was unable to retrieve information from %s for %s" $partialName $service $codeOwner }}
      {{- end }}
    {{- else }}
      {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }}
    {{- end }}
  {{- else if eq $type "group" }}
    {{- /* Uncomment to display groups. */}}
    {{- /* {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }} */}}
  {{- else if eq $type "email" }}
    {{- /* Uncomment to display email addresses. */}}
    {{- /* {{- $codeOwners = $codeOwners | append (dict "type" $type "name" $name) }} */}}
  {{- end }}
{{- end }}

{{- /* Render. */}}
{{- with $codeOwners }}
  <div class="codeowners-container">
    {{- range (sort $codeOwners "type" "desc") }}
      <div class="codeowner">
        {{- if and .link .img }}
          <div class="codeowner-image">
            <a href="{{ .link }}" rel="external">
              <img src="{{ .img.RelPermalink }}" width="{{ .img.Width }}" height="{{ .img.Height }}" alt="{{ .name }}">
            </a>
          </div>
          <div class="codeowner-link">
            <a href="{{ .link }}" rel="external">{{ .name }}</a>
          </div>
        {{- else if .link }}
          <div class="codeowner-link">
            <a href="{{ .link }}" rel="external">{{ .name }}</a>
          </div>
        {{- else }}
          <div class="codeowner-name">
            {{ .name }}
          </div>
        {{- end }}
      </div>
    {{- end }}
  </div>
{{- end -}}

Appearance

Here’s some Sass to get you started.

.codeowners-container {
  display: flex;
  flex-direction: column;
  font-size: 0.9375rem;
  gap: 0.5rem;
  margin: 1rem 0;
  max-width: 15rem;
  .codeowner {
    display: flex;
    gap: 0.5rem;
    .codeowner-image img {
      border-radius: 4px;
    }
    .codeowner-link {
      margin-left: 0.5rem;
    }
  }
}
Last modified: