cli icon indicating copy to clipboard operation
cli copied to clipboard

Add support for setting custom properties of a repository

Open gberche-orange opened this issue 1 year ago • 4 comments

Describe the feature or problem you’d like to solve

I need to set in batch custom properties on a set of repositories

Proposed solution

How will it benefit CLI and its users?

Collaborate with other org users on using custom properties (e.g. flag some repos as candidate for archiving or deletion)

Additional context

https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization

gberche-orange avatar Jun 25 '24 10:06 gberche-orange

You could use gh api in the meantime. GitHub's REST API reference includes gh usage snippets, for example:

gh api \
  --method PATCH \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  /repos/OWNER/REPO/properties/values \
   -f "properties[][property_name]=environment" -f "properties[][value]=production" -f "properties[][property_name]=service" -f "properties[][value]=web" -f "properties[][property_name]=team" -f "properties[][value]=octocat"

https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2022-11-28#create-or-update-custom-property-values-for-a-repository

benelan avatar Jun 27 '24 02:06 benelan

@gberche-orange : could you share how you are using the custom properties as part of your overall gh workflow?

Beyond setting the value of a custom property for a given repository, there are likely several ways that custom properties should be incorporated into gh more broadly:

  1. Custom property management
    1. Listing custom properties at the organization level
    2. View specific custom property at the organization level including property values
    3. Create custom properties at the organization level as a single or batch
    4. Remove custom property at the organization level
  2. Applying custom properties on repositories
    1. View custom property set for a given repository
    2. Set custom property value for a given repository
  3. Leveraging custom properties
    1. gh search repos

andyfeller avatar Aug 05 '24 19:08 andyfeller

@andyfeller Thanks for considering this feature request.

For my use case, the features 2i, 2ii, 3i you listed would be of most value. I would not yet need to use the cli for the actual property management and keep using the web ui.

My primary use case is automating batch annotation of repos by custom properties:

  • get a (long) list of repos matching some criterias (e.g. that should be archived because little activity since ... )
  • apply a custom property to each (e.g. "candidate-for-archival:true")

Then ask team members to review list of repos with custom property "candidate-for-archival:true"

gberche-orange avatar Aug 27 '24 15:08 gberche-orange

How might these new commands look within gh?

Ideating

# Spreading custom properties across multiple commands using long name...
gh org custom-property list <org>
gh org custom-property view <org> <property name>
gh org custom-property create <org> [ --property <property name> --type <property type> ]
gh org custom-property delete <org> <property name>
gh repo custom-property view [ --repo <owner>/<name> ]
gh repo custom-property set <property name> [ --repo <owner>/<name> ]( -f, --raw-field key=value )( -F, --field key=value )

# Using short name...
gh org property list <org>
gh org property view <org> <property name>
gh org property create <org> <property name>
gh org property delete <org> <property name>
gh repo property view [ --repo <owner>/<name> ]
gh repo property set <property name> [ --repo <owner>/<name> ]( -f, --raw-field key=value )( -F, --field key=value )

# Consolidating custom properties under its own command set using long name...
gh custom-property org list <org>
gh custom-property org view <org> <property name>
gh custom-property org create <org> [ --property <property name> --type <property type> ]
gh custom-property org delete <org> <property name>
gh custom-property repo view [ --repo <owner>/<name> ]
gh custom-property repo set <property name> [ --repo <owner>/<name> ]( -f, --raw-field key=value )( -F, --field key=value )

# Using short name...
gh property org list <org>
gh property org view <org> <property name>
gh property org create <org> [ --property <property name> --type <property type> ]
gh property org delete <org> <property name>
gh property repo view [ --repo <owner>/<name> ]
gh property repo set <property name> [ --repo <owner>/<name> ]( -f, --raw-field key=value )( -F, --field key=value )

# Searching is likely just a new flag that can be specified 0+ times
gh search repos --property="<property name>:<property value>"

Introspection

Looking at the current design of GitHub primitives and commands in gh, there's a little bit of a challenge figuring where custom properties fits:

  • Custom properties are defined at the organization level with REST APIs living under Organizations > Custom Properties
  • Custom properties are assigned to repository level with REST APIs living under Repositories > Custom Properties
  • Custom properties are available from Get a repository REST endpoint, however they don't appear to be available currently from GraphQL which we use to fetch repo information
  • GitHub search has a special qualifier for searching by custom properties: props.PROPERTY:VALUE
  • REST APIs around repository custom properties don't have an explicit delete or unset to clear a specific custom property, which might require it to be a blank value and then the org custom property default value will be applied 🤔
Example of GET /repos/{owner}/{repo} including custom properties

$ gh api /repos/tinyfists/actions-experiments
{
  "id": 558999734,
  "node_id": "R_kgDOIVGotg",
  "name": "actions-experiments",
  "full_name": "tinyfists/actions-experiments",
  "private": true,
  "owner": {
    "login": "tinyfists",
    "id": 90800543,
    "node_id": "MDEyOk9yZ2FuaXphdGlvbjkwODAwNTQz",
    "avatar_url": "https://avatars.githubusercontent.com/u/90800543?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/tinyfists",
    "html_url": "https://github.com/tinyfists",
    "followers_url": "https://api.github.com/users/tinyfists/followers",
    "following_url": "https://api.github.com/users/tinyfists/following{/other_user}",
    "gists_url": "https://api.github.com/users/tinyfists/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/tinyfists/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/tinyfists/subscriptions",
    "organizations_url": "https://api.github.com/users/tinyfists/orgs",
    "repos_url": "https://api.github.com/users/tinyfists/repos",
    "events_url": "https://api.github.com/users/tinyfists/events{/privacy}",
    "received_events_url": "https://api.github.com/users/tinyfists/received_events",
    "type": "Organization",
    "site_admin": false
  },
  "html_url": "https://github.com/tinyfists/actions-experiments",
  "description": "Collection of GitHub Action workflows for experimenting with various techniques.",
  "fork": false,
  "url": "https://api.github.com/repos/tinyfists/actions-experiments",
  "forks_url": "https://api.github.com/repos/tinyfists/actions-experiments/forks",
  "keys_url": "https://api.github.com/repos/tinyfists/actions-experiments/keys{/key_id}",
  "collaborators_url": "https://api.github.com/repos/tinyfists/actions-experiments/collaborators{/collaborator}",
  "teams_url": "https://api.github.com/repos/tinyfists/actions-experiments/teams",
  "hooks_url": "https://api.github.com/repos/tinyfists/actions-experiments/hooks",
  "issue_events_url": "https://api.github.com/repos/tinyfists/actions-experiments/issues/events{/number}",
  "events_url": "https://api.github.com/repos/tinyfists/actions-experiments/events",
  "assignees_url": "https://api.github.com/repos/tinyfists/actions-experiments/assignees{/user}",
  "branches_url": "https://api.github.com/repos/tinyfists/actions-experiments/branches{/branch}",
  "tags_url": "https://api.github.com/repos/tinyfists/actions-experiments/tags",
  "blobs_url": "https://api.github.com/repos/tinyfists/actions-experiments/git/blobs{/sha}",
  "git_tags_url": "https://api.github.com/repos/tinyfists/actions-experiments/git/tags{/sha}",
  "git_refs_url": "https://api.github.com/repos/tinyfists/actions-experiments/git/refs{/sha}",
  "trees_url": "https://api.github.com/repos/tinyfists/actions-experiments/git/trees{/sha}",
  "statuses_url": "https://api.github.com/repos/tinyfists/actions-experiments/statuses/{sha}",
  "languages_url": "https://api.github.com/repos/tinyfists/actions-experiments/languages",
  "stargazers_url": "https://api.github.com/repos/tinyfists/actions-experiments/stargazers",
  "contributors_url": "https://api.github.com/repos/tinyfists/actions-experiments/contributors",
  "subscribers_url": "https://api.github.com/repos/tinyfists/actions-experiments/subscribers",
  "subscription_url": "https://api.github.com/repos/tinyfists/actions-experiments/subscription",
  "commits_url": "https://api.github.com/repos/tinyfists/actions-experiments/commits{/sha}",
  "git_commits_url": "https://api.github.com/repos/tinyfists/actions-experiments/git/commits{/sha}",
  "comments_url": "https://api.github.com/repos/tinyfists/actions-experiments/comments{/number}",
  "issue_comment_url": "https://api.github.com/repos/tinyfists/actions-experiments/issues/comments{/number}",
  "contents_url": "https://api.github.com/repos/tinyfists/actions-experiments/contents/{+path}",
  "compare_url": "https://api.github.com/repos/tinyfists/actions-experiments/compare/{base}...{head}",
  "merges_url": "https://api.github.com/repos/tinyfists/actions-experiments/merges",
  "archive_url": "https://api.github.com/repos/tinyfists/actions-experiments/{archive_format}{/ref}",
  "downloads_url": "https://api.github.com/repos/tinyfists/actions-experiments/downloads",
  "issues_url": "https://api.github.com/repos/tinyfists/actions-experiments/issues{/number}",
  "pulls_url": "https://api.github.com/repos/tinyfists/actions-experiments/pulls{/number}",
  "milestones_url": "https://api.github.com/repos/tinyfists/actions-experiments/milestones{/number}",
  "notifications_url": "https://api.github.com/repos/tinyfists/actions-experiments/notifications{?since,all,participating}",
  "labels_url": "https://api.github.com/repos/tinyfists/actions-experiments/labels{/name}",
  "releases_url": "https://api.github.com/repos/tinyfists/actions-experiments/releases{/id}",
  "deployments_url": "https://api.github.com/repos/tinyfists/actions-experiments/deployments",
  "created_at": "2022-10-28T19:38:34Z",
  "updated_at": "2024-07-30T18:51:01Z",
  "pushed_at": "2024-08-12T14:06:31Z",
  "git_url": "git://github.com/tinyfists/actions-experiments.git",
  "ssh_url": "[email protected]:tinyfists/actions-experiments.git",
  "clone_url": "https://github.com/tinyfists/actions-experiments.git",
  "svn_url": "https://github.com/tinyfists/actions-experiments",
  "homepage": null,
  "size": 63,
  "stargazers_count": 0,
  "watchers_count": 0,
  "language": null,
  "has_issues": true,
  "has_projects": true,
  "has_downloads": true,
  "has_wiki": true,
  "has_pages": false,
  "has_discussions": true,
  "forks_count": 0,
  "mirror_url": null,
  "archived": false,
  "disabled": false,
  "open_issues_count": 37,
  "license": null,
  "allow_forking": true,
  "is_template": false,
  "web_commit_signoff_required": false,
  "topics": [],
  "visibility": "internal",
  "forks": 0,
  "open_issues": 37,
  "watchers": 0,
  "default_branch": "main",
  "permissions": {
    "admin": true,
    "maintain": true,
    "push": true,
    "triage": true,
    "pull": true
  },
  "temp_clone_token": "AAP6GD23G2SULDO3INVBMKDGZYNUK",
  "allow_squash_merge": true,
  "allow_merge_commit": true,
  "allow_rebase_merge": true,
  "allow_auto_merge": false,
  "delete_branch_on_merge": false,
  "allow_update_branch": false,
  "use_squash_pr_title_as_default": false,
  "squash_merge_commit_message": "COMMIT_MESSAGES",
  "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
  "merge_commit_message": "PR_TITLE",
  "merge_commit_title": "MERGE_MESSAGE",
  "custom_properties": {
    "foo": "just something",
    "foo2": "bar"
  },
  "organization": {
    "login": "tinyfists",
    "id": 90800543,
    "node_id": "MDEyOk9yZ2FuaXphdGlvbjkwODAwNTQz",
    "avatar_url": "https://avatars.githubusercontent.com/u/90800543?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/tinyfists",
    "html_url": "https://github.com/tinyfists",
    "followers_url": "https://api.github.com/users/tinyfists/followers",
    "following_url": "https://api.github.com/users/tinyfists/following{/other_user}",
    "gists_url": "https://api.github.com/users/tinyfists/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/tinyfists/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/tinyfists/subscriptions",
    "organizations_url": "https://api.github.com/users/tinyfists/orgs",
    "repos_url": "https://api.github.com/users/tinyfists/repos",
    "events_url": "https://api.github.com/users/tinyfists/events{/privacy}",
    "received_events_url": "https://api.github.com/users/tinyfists/received_events",
    "type": "Organization",
    "site_admin": false
  },
  "security_and_analysis": {
    "advanced_security": {
      "status": "enabled"
    },
    "secret_scanning": {
      "status": "enabled"
    },
    "secret_scanning_push_protection": {
      "status": "disabled"
    },
    "dependabot_security_updates": {
      "status": "disabled"
    },
    "secret_scanning_non_provider_patterns": {
      "status": "disabled"
    },
    "secret_scanning_validity_checks": {
      "status": "disabled"
    }
  },
  "network_count": 0,
  "subscribers_count": 0
}

https://github.com/cli/cli/blob/b8db372d71bd7dd6ca77ebd8e95f47cb033760ab/api/queries_repo.go#L249-L332

andyfeller avatar Aug 27 '24 18:08 andyfeller

This would clearly be very useful. Still, sadly it seems that even gh repo list is unable to retrieve or filter by custom properties.

One very likely use case for custom properties is to use them to group repositories, like adding a team custom property to identify which team own the repository. Also adding a status to declare the lifecycle status of a repository.

Sadly I was not able to find even an api way to retrieve repos by a custom field value for a given organization, maybe someone has a clue? Obviously that looking over each repo would not be a suitable solution due to the number of API calls.

ssbarnea avatar Apr 23 '25 13:04 ssbarnea

gh repo list --json custom-properties would be welcome as well

Kroppeb avatar Sep 08 '25 13:09 Kroppeb

My own current selfish interest in this is when creating a repo via automation. Our enterprise has things set so that you have to fill in a service_id custom field which they validate. Because I have to create then patch, I always get a nag notice from them about an invalid cost center even though it's been patched in the very next CLI command.

# Create the repository
gh repo create "$ORG/$REPO_NAME" \
    --private \
    --description "$DESCRIPTION" \
    --source=. \
    --remote=origin \
    --push

# Set the service_id property
echo "Setting service_id property..."
echo '{"properties":[{"property_name":"service_id","value":"xxxxxx"}]}' | \
    gh api --method PATCH "repos/$ORG/$REPO_NAME/properties/values" --input -

roboweaver avatar Sep 15 '25 22:09 roboweaver