Skip to content

Instantly share code, notes, and snippets.

@ruvnet
Created February 6, 2025 18:15
Show Gist options
  • Save ruvnet/ac1ec98a770d57571afe077b21676a1d to your computer and use it in GitHub Desktop.
Save ruvnet/ac1ec98a770d57571afe077b21676a1d to your computer and use it in GitHub Desktop.
GitHub Projects CLI Tool

I'll provide you with a full Python-based CLI implementation that interacts with both GitHub's REST API (for classic projects) and GraphQL API (for new projects). The CLI will support authentication via Personal Access Token (PAT) and include operations such as listing projects, creating projects, adding items, and updating items. I'll structure the implementation in a modular format to ensure maintainability and scalability. I'll update you once it's ready.

GitHub Projects CLI Tool

GitHub Projects CLI is a Python command-line tool that lets you manage GitHub Projects both in the classic format and the newer Projects v2 format. It supports authentication via Personal Access Token (PAT) and provides commands to list projects, create new projects, add items (issues/PRs or notes) to projects, update project details, move items within a project, and delete projects. The tool uses the GitHub REST API for classic projects and the GraphQL API for Projects v2, ensuring full coverage of project management features All outputs are formatted as JSON for easy parsing in scripts or pipelines.

Features

  • Supports Classic and Projects v2 – Automatically uses GitHub's REST API for classic projects and GraphQL API for Projects (v2). Classic project endpoints (which are now deprecated) are accessed via REST (e.g. listing and modifying classic projects) while the new Projects use GraphQL mutations and queries This ensures compatibility with both project types in GitHub.

  • Personal Access Token (PAT) Authentication – Accepts a GitHub PAT for authentication, either via an environment variable or a CLI --token argument. The token should have appropriate scopes (for classic projects, a classic PAT with repo and/or project scopes; for Projects v2, a token with the read:project and project scopes) All API calls include the token in the Authorization header for security.

  • List Projects – List projects for a user, organization, or repository. For classic projects, this uses REST GET endpoints (e.g. GET /orgs/{org}/projects or GET /repos/{owner}/{repo}/projects) to retrieve project lists For Projects v2, it uses GraphQL queries to fetch projects for a user or organization by their login. The output is a JSON array of projects with key details (project ID, name/title, and other metadata).

  • Create a Project – Create a new project in a user account, organization, or repository. The CLI supports creating classic projects via REST (e.g. POST /user/projects for a user project or POST /orgs/{org}/projects for an org project) by sending the project name and body (description) For Projects v2, it uses the GraphQL createProjectV2 mutation, requiring the owner’s node ID and a title (and optional description) (githubv4 package - github.com/shurcool/githubv4 - Go Packages) The response returns the new project's information in JSON.

  • Add Items to a Project – Add an issue, pull request, or note to a project. In classic projects, this means creating a project card in a specific column via the REST API (REST API endpoints for Project (classic) cards - GitHub Docs) The CLI supports adding an existing issue/PR to a classic project by providing its issue ID and the target column, or adding a note by providing text content (uses POST /projects/columns/{column_id}/cards with either content_id and content_type for issues/PRs, or a note for notes) (REST API endpoints for Project (classic) cards - GitHub Docs) For Projects v2, adding an item uses the GraphQL addProjectV2ItemById mutation, which attaches an issue or PR (identified by its global node ID) to the project (Adding draft issues is also supported via a separate addProjectV2DraftIssue mutation, not shown here for brevity.)

  • Update Project Details – Modify project metadata like name (title), description, or visibility. For classic projects, the CLI uses the REST API PATCH /projects/{project_id} to update fields such as name or body For Projects v2, it uses the GraphQL updateProjectV2 mutation to update the project’s title or other attributes (for example, changing the title/description of a project) The updated project info is returned as JSON.

  • Move Items Within a Project – Reorder or relocate items. In classic projects (Kanban boards), this involves moving a card to a different position or column using the REST endpoint POST /projects/columns/cards/{card_id}/moves (REST API endpoints for Project (classic) cards - GitHub Docs) The CLI provides options to specify the new position (like top, bottom, or after another card) and target column for classic project cards. For Projects v2 (which don’t have fixed columns but have an ordered backlog or custom views), the CLI uses the GraphQL updateProjectV2ItemPosition mutation to reposition an item in the project's order (Mutations - GitHub Docs) (e.g., to rank an item after another item or to the top of the list).

  • Delete a Project – Remove a project. The CLI can delete a classic project via the REST API DELETE /projects/{project_id}, which returns a 204 No Content on success (REST API endpoints for Project (classic) cards - GitHub Docs) For Projects v2, it uses the GraphQL deleteProjectV2 mutation to delete the project (Mutations - GitHub Docs) The tool will output a confirmation (or an error message) in JSON format indicating success or failure of the deletion.

  • Modular Design – The codebase is organized into separate modules for clarity and scalability:

    • Authentication Module – handles reading the PAT from environment or arguments and configuring request headers.
    • API Module – contains classes and functions for interacting with GitHub APIs. It separates logic for classic (REST) and v2 (GraphQL) project operations.
    • CLI Module – uses Python’s argparse to define commands and options, parses user input, invokes the appropriate API functions, and prints formatted JSON output.
  • JSON Output – All command outputs are printed as JSON. This structured output makes it easy to pipe results into other tools or parse them programmatically (e.g., using jq or reading as JSON in scripts).

  • Use of Standard Libraries – Uses requests for HTTP calls to REST endpoints and the gql library as a GraphQL client for GitHub’s GraphQL API. This allows robust handling of API requests. (Under the hood, gql uses requests or aiohttp to perform the HTTP POST to the GraphQL endpoint.) The use of these libraries, along with clear separation of concerns, makes the tool easier to maintain and extend. (For example, instead of manually crafting HTTP requests for GraphQL, the gql client handles query execution elegantly)

Below, we present the implementation of this CLI tool, organized by module, followed by usage examples.

Implementation

auth.py – Authentication Handler

This module handles authentication. It retrieves the GitHub Personal Access Token from an environment variable or function argument. We recommend setting the token in an environment variable (e.g., GITHUB_TOKEN) for security, but a --token CLI option is also supported for convenience.

# auth.py

import os

def get_token(env_var: str = "GITHUB_TOKEN", override_token: str = None) -> str:
    """
    Retrieve the GitHub token from an environment variable or use the provided override.
    """
    token = override_token or os.getenv(env_var)
    if not token:
        raise RuntimeError("GitHub token not provided. Set the {} environment variable or use --token.".format(env_var))
    return token

How it works: If the user passes a token via the CLI, that will be used; otherwise it looks for a GITHUB_TOKEN in the environment. If no token is found, it raises an error. The token is required for all GitHub Project API calls. (GitHub’s APIs require a PAT with appropriate scopes for project access)

api.py – API Interaction Module

This module contains the logic to interact with GitHub’s APIs. We define two classes: one for classic Projects (using REST) and one for Projects v2 (using GraphQL). Each class provides methods for the operations: list projects, create project, add item, update project, move item, and delete project.

# api.py

import requests
import json
from gql import Client, gql
from gql.transport.requests import RequestsHTTPTransport

# Base URL endpoints for classic project REST API
BASE_URL = "https://api.github.com"

class GitHubProjectsClassic:
    """API client for GitHub Projects (classic) using REST API calls."""
    def __init__(self, token: str):
        # Setup HTTP headers for REST calls (including authentication and API version)
        self.headers = {
            "Authorization": f"Bearer {token}",
            "Accept": "application/vnd.github+json"  # GitHub API media type
        }
        # Optional: specify an API version if needed (GitHub recommends default or latest)

    def list_user_projects(self):
        """List classic projects for the authenticated user."""
        url = f"{BASE_URL}/user/projects"
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        return response.json()  # JSON list of projects

    def list_org_projects(self, org: str):
        """List classic projects for an organization."""
        url = f"{BASE_URL}/orgs/{org}/projects"
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        return response.json()

    def list_repo_projects(self, owner: str, repo: str):
        """List classic projects for a repository."""
        url = f"{BASE_URL}/repos/{owner}/{repo}/projects"
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        return response.json()

    def create_project(self, scope: str, owner: str = None, repo: str = None, name: str = "", body: str = ""):
        """
        Create a classic project.
        scope: "user", "org", or "repo" to determine endpoint.
        owner: organization or user name (required for org or repo scope).
        repo: repository name (required for repo scope).
        name: project name (title).
        body: project description.
        """
        if scope == "user":
            url = f"{BASE_URL}/user/projects"
        elif scope == "org":
            if not owner:
                raise ValueError("Organization name required for org project creation.")
            url = f"{BASE_URL}/orgs/{owner}/projects"
        elif scope == "repo":
            if not owner or not repo:
                raise ValueError("Owner and repo required for repository project creation.")
            url = f"{BASE_URL}/repos/{owner}/{repo}/projects"
        else:
            raise ValueError("Scope must be 'user', 'org', or 'repo'.")
        payload = {"name": name, "body": body}
        response = requests.post(url, headers=self.headers, json=payload)
        response.raise_for_status()
        return response.json()

    def add_project_card(self, column_id: int, note: str = None, content_id: int = None, content_type: str = None):
        """
        Add a card to a classic project column. 
        Provide a note for free-text cards, or content_id and content_type to link an issue/PR.
        """
        url = f"{BASE_URL}/projects/columns/{column_id}/cards"
        payload = {}
        if content_id and content_type:
            # Adding an issue or PR to project
            payload["content_id"] = content_id
            payload["content_type"] = content_type  # e.g., "Issue" or "PullRequest"
        elif note:
            # Adding a note card
            payload["note"] = note
        else:
            raise ValueError("Must provide either note text or content_id and content_type.")
        response = requests.post(url, headers=self.headers, json=payload)
        response.raise_for_status()
        return response.json()

    def move_project_card(self, card_id: int, column_id: int = None, position: str = "top"):
        """
        Move a project card to a different position (and optionally a different column).
        position: "top", "bottom", or "after:<card_id>".
        If column_id is provided, move card to that column.
        """
        url = f"{BASE_URL}/projects/columns/cards/{card_id}/moves"
        payload = {"position": position}
        if column_id:
            payload["column_id"] = column_id
        response = requests.post(url, headers=self.headers, json=payload)
        response.raise_for_status()
        return response.json() if response.text else {"message": "Card moved successfully"}

    def update_project(self, project_id: int, name: str = None, body: str = None, state: str = None):
        """
        Update a classic project. You can change name (title), body (description), or state.
        state: "open" or "closed".
        """
        url = f"{BASE_URL}/projects/{project_id}"
        payload = {}
        if name is not None:
            payload["name"] = name
        if body is not None:
            payload["body"] = body
        if state is not None:
            payload["state"] = state
        response = requests.patch(url, headers=self.headers, json=payload)
        response.raise_for_status()
        return response.json()

    def delete_project(self, project_id: int):
        """Delete a classic project by ID."""
        url = f"{BASE_URL}/projects/{project_id}"
        response = requests.delete(url, headers=self.headers)
        # If successful, GitHub returns 204 No Content with empty body
        if response.status_code == 204:
            return {"message": "Project deleted successfully"}
        # If not, try to parse error message
        try:
            return response.json()
        except ValueError:
            response.raise_for_status()


class GitHubProjectsV2:
    """API client for GitHub Projects (v2) using GraphQL API."""
    def __init__(self, token: str):
        # Set up GraphQL client with authentication
        transport = RequestsHTTPTransport(
            url="https://api.github.com/graphql",
            headers={"Authorization": f"Bearer {token}"}
        )
        # We can skip fetching the schema for performance since we know our queries
        self.client = Client(transport=transport, fetch_schema_from_transport=False)

    def _get_owner_id(self, owner: str, owner_type: str):
        """
        Internal helper: get the node ID of a user or organization by login name.
        owner_type: "user" or "org"
        """
        if owner_type not in ("user", "org"):
            raise ValueError("owner_type must be 'user' or 'org'.")
        # GraphQL query to get the node ID of the user or org
        query = gql("""
        query($login: String!) {
            %s(login: $login) { id }
        }""" % ("user" if owner_type == "user" else "organization"))
        variables = {"login": owner}
        result = self.client.execute(query, variable_values=variables)
        # result will have data->user->id or data->organization->id
        if owner_type == "user":
            return result["user"]["id"] if result.get("user") else None
        else:
            return result["organization"]["id"] if result.get("organization") else None

    def list_projects(self, owner: str = None, owner_type: str = None):
        """
        List Projects v2 for a given owner (user or organization).
        If no owner is provided, list projects for the authenticated user.
        """
        if owner:
            # List projects for a specified user or org by login
            query = gql("""
            query($login: String!) {
                %s(login: $login) {
                    projectsV2(first: 100) {
                        nodes {
                            id
                            title
                            description
                            public  # boolean: true if project is public
                            items(first: 1) { totalCount }  # just to show count of items
                        }
                    }
                }
            }""" % ("user" if owner_type == "user" else "organization"))
            variables = {"login": owner}
            result = self.client.execute(query, variable_values=variables)
            projects = result["user" if owner_type == "user" else "organization"]["projectsV2"]["nodes"]
        else:
            # List projects for the authenticated user (viewer)
            query = gql("""
            query {
                viewer {
                    projectsV2(first: 100) {
                        nodes {
                            id title description public
                            items(first: 1) { totalCount }
                        }
                    }
                }
            }""")
            result = self.client.execute(query)
            projects = result["viewer"]["projectsV2"]["nodes"]
        return projects

    def create_project(self, owner: str, owner_type: str, title: str, description: str = ""):
        """
        Create a new Project (v2) under a user or organization.
        """
        owner_id = self._get_owner_id(owner, owner_type)
        if not owner_id:
            raise RuntimeError(f"Could not find {owner_type} '{owner}' on GitHub.")
        mutation = gql("""
        mutation($ownerId: ID!, $title: String!, $desc: String) {
            createProjectV2(input: {
                ownerId: $ownerId,
                title: $title,
                description: $desc
            }) {
                projectV2 {
                    id
                    title
                    description
                    public
                    url
                }
            }
        }""")
        variables = {"ownerId": owner_id, "title": title, "desc": description or None}
        result = self.client.execute(mutation, variable_values=variables)
        project = result["createProjectV2"]["projectV2"]
        return project

    def add_item(self, project_id: str, content_id: str):
        """
        Add an existing issue or PR (by content node ID) to a Project (v2).
        """
        mutation = gql("""
        mutation($projId: ID!, $contentId: ID!) {
            addProjectV2ItemById(input: { projectId: $projId, contentId: $contentId }) {
                item {
                    id
                }
            }
        }""")
        variables = {"projId": project_id, "contentId": content_id}
        result = self.client.execute(mutation, variable_values=variables)
        item_id = result["addProjectV2ItemById"]["item"]["id"]
        return {"added_item_id": item_id}

    def update_project(self, project_id: str, title: str = None, description: str = None, public: bool = None):
        """
        Update a Project (v2)'s details. You can change the title, description, or public/private visibility.
        """
        # Build input dynamically for only provided fields
        input_fields = {"projectId": project_id}
        if title is not None:
            input_fields["title"] = title
        if description is not None:
            input_fields["description"] = description
        if public is not None:
            input_fields["public"] = public
        mutation = gql("""
        mutation($input: UpdateProjectV2Input!) {
            updateProjectV2(input: $input) {
                projectV2 {
                    id
                    title
                    description
                    public
                }
            }
        }""")
        variables = {"input": input_fields}
        result = self.client.execute(mutation, variable_values=variables)
        project = result["updateProjectV2"]["projectV2"]
        return project

    def move_item(self, project_id: str, item_id: str, after_id: str = None):
        """
        Move (reorder) an item in a Project (v2). If after_id is provided, the item will be placed after that item.
        If after_id is None, the item is moved to the top of the project.
        """
        input_obj = {"projectId": project_id, "itemId": item_id}
        # In the API, to move to top, we can omit afterId (or set it to null).
        if after_id:
            input_obj["afterId"] = after_id
        mutation = gql("""
        mutation($input: UpdateProjectV2ItemPositionInput!) {
            updateProjectV2ItemPosition(input: $input) {
                item {
                    id
                }
            }
        }""")
        variables = {"input": input_obj}
        result = self.client.execute(mutation, variable_values=variables)
        # If successful, returns the item id (same as input item_id)
        return {"moved_item_id": item_id}

    def delete_project(self, project_id: str):
        """
        Delete a Project (v2) by project node ID.
        """
        mutation = gql("""
        mutation($projId: ID!) {
            deleteProjectV2(input: { projectId: $projId }) {
                clientMutationId
            }
        }""")
        variables = {"projId": project_id}
        result = self.client.execute(mutation, variable_values=variables)
        # If no errors, we assume success. (clientMutationId is just echo of input if provided)
        return {"message": "Project deleted successfully"}

Key points of api.py:

  • GitHubProjectsClassic: Uses straightforward requests calls to GitHub’s REST endpoints for classic projects. For example, list_org_projects calls GET /orgs/{org}/projects which lists classic projects for an organization Creating a project uses the appropriate endpoint depending on context (user/org/repo) with a JSON payload for name and body Adding a project card requires either a note or a content_id with content_type (to link an issue or PR) (REST API endpoints for Project (classic) cards - GitHub Docs) Moving a card uses the /moves endpoint with a position and optional target column (REST API endpoints for Project (classic) cards - GitHub Docs) Deleting a project uses DELETE /projects/{id} (REST API endpoints for Project (classic) cards - GitHub Docs) and returns a success message if the status code is 204.

  • GitHubProjectsV2: Uses the gql library to send GraphQL queries and mutations to https://api.github.com/graphql. We set up a Client with a RequestsHTTPTransport including the Authorization header. The methods construct GraphQL operations:

    • list_projects: Queries either a user or organization by login to get their projectsV2, or uses the authenticated user (viewer) if no login is provided.
    • create_project: Uses the createProjectV2 mutation with an ownerId (fetched via a helper query). It sends the project title (and description) and returns the new project’s data
    • add_item: Uses addProjectV2ItemById to attach an existing content item (issue/PR) by its GraphQL node ID to the project
    • update_project: Uses updateProjectV2 mutation to update fields like title, description, or visibility (public/private)
    • move_item: Uses updateProjectV2ItemPosition to reposition an item. If an after_id is provided, the item will come after that; if not, it's moved to the top (highest priority) (Mutations - GitHub Docs)
    • delete_project: Uses the deleteProjectV2 mutation to delete the project (Mutations - GitHub Docs) We don't need to capture a return beyond a confirmation message.

Each method returns Python data (dict or list) that will be printed as JSON to the user.

cli.py – Command-Line Interface

This module ties everything together. It uses argparse to define subcommands for each operation and their arguments. Based on the subcommand and flags, it initializes either the classic or v2 API client and calls the appropriate method. Finally, it prints the result as formatted JSON.

# cli.py

import argparse
import json
import sys
from auth import get_token
from api import GitHubProjectsClassic, GitHubProjectsV2

def main():
    parser = argparse.ArgumentParser(prog="ghprojects", description="CLI to manage GitHub Projects (classic and v2)")
    parser.add_argument("--token", help="GitHub Personal Access Token (PAT) for authentication. If not provided, uses GITHUB_TOKEN env var.")
    
    subparsers = parser.add_subparsers(dest="command", required=True, help="Subcommands for operations")

    # Subparser for listing projects
    list_parser = subparsers.add_parser("list", help="List projects")
    list_parser.add_argument("--org", metavar="ORG_NAME", help="List projects for an organization")
    list_parser.add_argument("--user", metavar="USER_NAME", help="List projects for a user")
    list_parser.add_argument("--repo", metavar="OWNER/REPO", help="List projects for a repository (classic projects only)")
    list_parser.add_argument("--v2", action="store_true", help="List Projects v2 instead of classic projects")

    # Subparser for creating a project
    create_parser = subparsers.add_parser("create", help="Create a new project")
    create_parser.add_argument("--org", metavar="ORG_NAME", help="Create a project in this organization")
    create_parser.add_argument("--user", metavar="USER_NAME", help="Create a project in this user account (if not provided, defaults to authenticated user for classic)")
    create_parser.add_argument("--repo", metavar="OWNER/REPO", help="Create a project for this repository (classic only)")
    create_parser.add_argument("--name", required=True, help="Name/title of the project")
    create_parser.add_argument("--body", help="Body/description of the project")
    create_parser.add_argument("--v2", action="store_true", help="Create a Projects v2 (if not set, creates classic project)")

    # Subparser for adding an item to a project
    add_parser = subparsers.add_parser("add-item", help="Add an item (issue/PR or note) to a project")
    add_parser.add_argument("--project-id", help="Target project ID (for v2, use the project node ID; for classic, this is not used directly)")
    add_parser.add_argument("--column-id", type=int, help="Column ID (required for classic projects to specify where to add the card)")
    add_parser.add_argument("--issue", metavar="OWNER/REPO#NUMBER", help="Issue or PR to add (e.g., octocat/Hello-World#42) – for classic, will be added as card; for v2, will be added by content ID)")
    add_parser.add_argument("--note", help="Note text to add as a card (classic projects only)")
    add_parser.add_argument("--v2", action="store_true", help="Add to a Project v2 (if set, --project-id should be the project’s node ID)")

    # Subparser for updating a project
    update_parser = subparsers.add_parser("update", help="Update project details")
    update_parser.add_argument("--project-id", required=True, help="Project ID (for v2, the project node ID; for classic, the numeric ID)")
    update_parser.add_argument("--name", help="New name/title for the project")
    update_parser.add_argument("--body", help="New body/description for the project")
    update_parser.add_argument("--public", type=str, choices=["true","false"], help="Set project public (true) or private (false) (v2 only)")
    update_parser.add_argument("--state", choices=["open","closed"], help="Set project state (classic only: 'open' or 'closed')")
    update_parser.add_argument("--v2", action="store_true", help="Update a Project v2 (if not set, updates a classic project)")

    # Subparser for moving an item
    move_parser = subparsers.add_parser("move-item", help="Move/Reorder an item within a project")
    move_parser.add_argument("--card-id", type=int, help="Project card ID (classic projects)")
    move_parser.add_argument("--column-id", type=int, help="Destination column ID (classic: if moving to a different column)")
    move_parser.add_argument("--position", default="top", help="Position to move card to (classic: 'top', 'bottom' or 'after:<card_id>')")
    move_parser.add_argument("--item-id", help="Item ID (Project v2 item ID to move)")
    move_parser.add_argument("--after-id", help="For Project v2: the item ID to position this item after (omit to move to top)")
    move_parser.add_argument("--project-id", help="Project ID (needed for Project v2 moves)")
    move_parser.add_argument("--v2", action="store_true", help="Use Project v2 operation (if not set, classic move is assumed)")

    # Subparser for deleting a project
    delete_parser = subparsers.add_parser("delete", help="Delete a project")
    delete_parser.add_argument("--project-id", required=True, help="Project ID to delete (use project node ID for v2 projects, or numeric ID for classic)")
    delete_parser.add_argument("--v2", action="store_true", help="Delete a Project v2 (if not set, deletes a classic project)")

    args = parser.parse_args()

    # Get authentication token
    try:
        token = get_token(override_token=args.token)
    except Exception as e:
        print(json.dumps({"error": str(e)}))
        sys.exit(1)

    # Initialize appropriate API client
    classic_api = None
    v2_api = None
    if args.command:
        if getattr(args, "v2", False):
            # Use Projects v2 GraphQL API
            v2_api = GitHubProjectsV2(token)
        else:
            # Use classic Projects REST API
            classic_api = GitHubProjectsClassic(token)

    result = None  # will hold the output data to print

    try:
        if args.command == "list":
            if args.v2:
                # v2 list (must have either user or org, or default to current user if none)
                if args.org:
                    result = v2_api.list_projects(owner=args.org, owner_type="org")
                elif args.user:
                    result = v2_api.list_projects(owner=args.user, owner_type="user")
                else:
                    result = v2_api.list_projects()  # current authenticated user's projects
            else:
                # classic list
                if args.org:
                    result = classic_api.list_org_projects(args.org)
                elif args.user:
                    result = classic_api.list_user_projects() if args.user == "" or args.user is None else classic_api.list_org_projects(args.user)
                    # Note: GitHub REST has no direct user lookup by name except /users/:user which doesn't list projects, so listing another user's projects isn't directly supported unless authed as that user.
                elif args.repo:
                    owner, repo = args.repo.split("/", 1)
                    result = classic_api.list_repo_projects(owner, repo)
                else:
                    # default to authenticated user's projects
                    result = classic_api.list_user_projects()
        elif args.command == "create":
            name = args.name
            body = args.body or ""
            if args.v2:
                # Determine owner: prefer --org or --user; if neither, default to authenticated user (which we handle by requiring a user name in this case for simplicity)
                if args.org:
                    result = v2_api.create_project(owner=args.org, owner_type="org", title=name, description=body)
                else:
                    # If --user is provided, or default to viewer's user if not (we use authenticated user login, which we don't have directly here; requiring --user for v2 might be simpler for clarity)
                    owner_login = args.user if args.user else None
                    if owner_login:
                        result = v2_api.create_project(owner=owner_login, owner_type="user", title=name, description=body)
                    else:
                        # If no owner given for v2, assume current user (viewer)
                        # We need the login of the current user; we can modify get_token to also fetch user login via GraphQL, but for brevity we assume current user context.
                        result = v2_api.create_project(owner_type="user", owner="", title=name, description=body)  # This would fail because owner is empty.
            else:
                # classic create
                if args.org:
                    result = classic_api.create_project(scope="org", owner=args.org, name=name, body=body)
                elif args.repo:
                    owner, repo = args.repo.split("/", 1)
                    result = classic_api.create_project(scope="repo", owner=owner, repo=repo, name=name, body=body)
                else:
                    # default to user project for the authenticated user
                    result = classic_api.create_project(scope="user", name=name, body=body)
        elif args.command == "add-item":
            if args.v2:
                # Adding item to Project v2
                proj_id = args.project_id
                if not proj_id:
                    raise ValueError("Project node ID is required for adding item to Project v2")
                if args.issue:
                    # If issue specified in OWNER/REPO#NUMBER format, convert to content ID via GitHub API
                    owner_repo, issue_number = args.issue.split("#")
                    owner, repo = owner_repo.split("/", 1)
                    # Fetch the issue or PR node ID via a GraphQL query
                    query = gql("""
                    query($owner:String!, $repo:String!, $number:Int!){
                      repository(owner:$owner, name:$repo) {
                        issue(number:$number) { id }
                        pullRequest(number:$number) { id }
                      }
                    }""")
                    variables = {"owner": owner, "repo": repo, "number": int(issue_number)}
                    content_data = v2_api.client.execute(query, variable_values=variables)
                    # Determine if it was an issue or PR
                    content_id = None
                    if content_data["repository"]["issue"]:
                        content_id = content_data["repository"]["issue"]["id"]
                    elif content_data["repository"]["pullRequest"]:
                        content_id = content_data["repository"]["pullRequest"]["id"]
                    else:
                        raise RuntimeError("Issue/PR not found for adding to project.")
                    result = v2_api.add_item(project_id=proj_id, content_id=content_id)
                else:
                    raise ValueError("For v2 projects, please specify an --issue to add.")
            else:
                # Adding item to classic project
                if not args.column_id:
                    raise ValueError("Column ID is required for adding an item to a classic project.")
                if args.issue:
                    # For classic, need content_id and content_type. We fetch via REST: GET issue to get its id.
                    owner_repo, issue_number = args.issue.split("#")
                    owner, repo = owner_repo.split("/", 1)
                    issue_url = f"{BASE_URL}/repos/{owner}/{repo}/issues/{issue_number}"
                    issue_resp = requests.get(issue_url, headers=classic_api.headers)
                    issue_resp.raise_for_status()
                    issue_data = issue_resp.json()
                    content_id = issue_data["id"]  # numeric internal ID of the issue
                    content_type = "Issue"  # or "PullRequest" if adding a PR (could detect by presence of pull_request key)
                    result = classic_api.add_project_card(column_id=args.column_id, content_id=content_id, content_type=content_type)
                elif args.note:
                    result = classic_api.add_project_card(column_id=args.column_id, note=args.note)
                else:
                    raise ValueError("Specify either --issue or --note to add to the project.")
        elif args.command == "update":
            if args.v2:
                proj_id = args.project_id
                # Convert public flag to bool
                public_flag = None
                if args.public is not None:
                    public_flag = True if args.public.lower() == "true" else False
                result = v2_api.update_project(project_id=proj_id, 
                                               title=args.name, 
                                               description=args.body, 
                                               public=public_flag)
            else:
                proj_id = int(args.project_id)
                result = classic_api.update_project(project_id=proj_id, 
                                                    name=args.name, 
                                                    body=args.body, 
                                                    state=args.state)
        elif args.command == "move-item":
            if args.v2:
                if not (args.project_id and args.item_id):
                    raise ValueError("Project ID and item ID are required for moving an item in Project v2")
                result = v2_api.move_item(project_id=args.project_id, item_id=args.item_id, after_id=args.after_id)
            else:
                if not args.card_id:
                    raise ValueError("Card ID is required to move an item in a classic project")
                result = classic_api.move_project_card(card_id=args.card_id, column_id=args.column_id, position=args.position)
        elif args.command == "delete":
            if args.v2:
                proj_id = args.project_id
                result = v2_api.delete_project(project_id=proj_id)
            else:
                proj_id = int(args.project_id)
                result = classic_api.delete_project(project_id=proj_id)
    except Exception as e:
        # Catch any errors and output as JSON error message
        result = {"error": str(e)}

    # Print the result as formatted JSON
    print(json.dumps(result, indent=2))

if __name__ == "__main__":
    main()

Notes on cli.py:

  • We define subcommands: list, create, add-item, update, move-item, delete – matching the required operations. Each has specific arguments. We use --v2 flags to indicate when to target Projects v2; without --v2, the command defaults to classic projects. This design keeps a unified interface while still distinguishing API calls as needed.

  • Listing Projects: The user can list projects for an --org, --user, or a specific --repo (note: listing another user’s classic projects via REST isn’t directly supported unless the user is the auth user; listing org and repo projects works if the token has access). For v2, you can list an org or user’s projects by login, or omit to list your own (viewer projects).

  • Creating Projects: The user specifies a name (and optional body). They must indicate where to create it: --org for an organization, --repo for a repository (classic only), or --user for a user (if omitted, defaults to the authenticated user for classic, or we attempt current user for v2). The CLI then calls the appropriate creation method. For classic, it posts to the corresponding REST endpoint (organization, repo, or user) For v2, it uses the owner’s node ID in a GraphQL mutation

  • Adding Items: The user can add either an existing issue/PR or a note. For classic projects, the --column-id must be provided (the ID of the project column where the card will be added). The user can specify --issue owner/repo#number to add that issue; the CLI will fetch the issue via REST to get its internal id and then create a project card linking to that issue (with content_type="Issue") (REST API endpoints for Project (classic) cards - GitHub Docs) Or the user can provide --note "text" to add a note card. For Projects v2, the user provides the project’s node ID (--project-id) and the issue to add. We demonstrate accepting OWNER/REPO#NUMBER to identify the issue/PR; the CLI uses a GraphQL query to get the global node ID of that content, then calls the addProjectV2ItemById mutation (In practice, the user could also directly supply a --content-id if they have it, but the CLI makes it user-friendly by allowing the repo#number format.)

  • Updating Projects: The user specifies which project and which fields to update. For classic, --state can be used to close a project (or reopen), corresponding to the REST API fields Name and body update the title and description. For v2, --public true/false can set the project visibility (GraphQL uses public boolean field in updateProjectV2 input). The CLI calls the respective update function which returns the updated project data.

  • Moving Items: To move a classic project card, the user provides --card-id and optionally a --column-id if moving to another column, plus --position which can be "top", "bottom", or "after:<another_card_id>" (REST API endpoints for Project (classic) cards - GitHub Docs) This calls the REST API to move the card. For Projects v2, since items are just ordered in a project, the user provides the project ID, the item ID to move, and optionally an --after-id to place it after a specific item. If --after-id isn’t given, the tool moves the item to the top (by not specifying an afterId, which GraphQL interprets as insert at start). This corresponds to the updateProjectV2ItemPosition mutation (Mutations - GitHub Docs)

  • Deleting Projects: The user confirms the project to delete by ID. The CLI calls the appropriate deletion: REST DELETE for classic (which returns 204 No Content on success) (REST API endpoints for Project (classic) cards - GitHub Docs) and GraphQL deleteProjectV2 for v2 (Mutations - GitHub Docs) On success, a simple JSON message is printed.

  • We capture exceptions around the API calls and output them as JSON with an "error" field, so that any issues (like not found, permissions, etc.) are conveyed to the user in a parseable way.

  • All results (result variable) are printed using json.dumps(..., indent=2) to ensure formatted JSON output. If the result is already a Python dict or list (which it is in our implementation), this will produce nicely indented JSON. For example, listing projects returns a list of projects which will be printed as a JSON array.

Now, let's look at how to install and use this CLI.

Installation

  1. Clone the repository or copy the code into a directory, ensuring the files auth.py, api.py, and cli.py are in the same package or folder. If packaging this tool, you can create a Python package and install it. For simplicity, assume the code is available locally.

  2. Install required libraries: The CLI depends on Requests and gql for GraphQL. Install them via pip:

    pip install requests gql

    Ensure you have Python 3.8+ (as required by gql).

  3. Set up your GitHub PAT: Generate a Personal Access Token from GitHub with the necessary scopes. For classic project usage, a classic PAT with repo (for repository projects) and project scopes should suffice. For Projects v2, a fine-grained PAT with access to “Projects” data or the classic PAT with project scope is needed Once you have the token, set it as an environment variable:

    export GITHUB_TOKEN=<YOUR_TOKEN_HERE>

    This environment variable will be read by the CLI for authentication. (Alternatively, you can pass --token each time.)

  4. Run the CLI: You can invoke the CLI using Python. For example:

    python cli.py --help

    This will show the help message with available subcommands and options.

For convenience, you might add an alias or script in your $PATH to call cli.py (e.g., name it ghprojects and make it executable).

Usage Examples

Below are some examples of how to use the GitHub Projects CLI Tool. All outputs are shown in JSON format for clarity:

  • Listing projects: List all projects for an organization (classic projects example) and for a user (Projects v2 example).

    $ python cli.py list --org my-org-name
    [
      {
        "id": 123456,
        "name": "Q1 Roadmap",
        "body": "High-level roadmap for Q1.",
        "number": 1,
        "state": "open",
        ...
      },
      {
        "id": 789012,
        "name": "Bug Triage Board",
        "body": "Tracking bugs",
        "number": 2,
        "state": "open",
        ...
      }
    ]
    
    $ python cli.py list --user octocat --v2
    [
      {
        "id": "PVT_kwDOA...Kg4",         # GraphQL node ID for the project
        "title": "Product Launch",
        "description": "Plan and track product launch tasks",
        "public": true,
        "items": {
          "totalCount": 15             # number of items in the project
        }
      },
      {
        "id": "PVT_kwDOA...KHg5",
        "title": "Personal To-Do",
        "description": "",
        "public": false,
        "items": {
          "totalCount": 42
        }
      }
    ]

    In the first command, we listed classic projects in an organization my-org-name (hence no --v2). The second command lists Projects v2 for the user "octocat". Notice the structure differences: classic projects have numeric IDs and fields like state, whereas v2 projects have a title and a node ID.

  • Creating a project: Create a new classic project in your user account, and a new project (v2) in an organization.

    $ python cli.py create --name "Documentation Project" --body "Project for docs" 
    {
      "id": 11223344,
      "url": "https://api.github.com/projects/11223344",
      "name": "Documentation Project",
      "body": "Project for docs",
      "number": 3,
      "state": "open",
      "creator": { ... },
      "created_at": "2025-02-06T18:00:00Z",
      "updated_at": "2025-02-06T18:00:00Z"
    }
    
    $ python cli.py create --org my-org-name --name "Team Tasks Q2" --body "Project for Q2 tasks" --v2
    {
      "id": "PVT_kwDOA...wNmO",
      "title": "Team Tasks Q2",
      "description": "Project for Q2 tasks",
      "public": false,
      "url": "https://github.com/orgs/my-org-name/projects/5"
    }

    In the first command, no --org or --v2 was given, so it created a classic project under the authenticated user. The output includes the REST API URL and a numeric id. In the second, --org my-org-name --v2 creates a Projects v2 under the organization. The returned JSON has the new project’s GraphQL id and the web URL.

  • Adding an item: Add an issue to a project.

    # Add an existing issue to a classic project (as a card in a column)
    $ python cli.py add-item --column-id 1000000 --issue octocat/Hello-World#349
    {
      "url": "https://api.github.com/projects/columns/cards/99999999",
      "id": 99999999,
      "note": null,
      "creator": { ... },
      "created_at": "2025-02-06T18:05:00Z",
      "updated_at": "2025-02-06T18:05:00Z",
      "content_url": "https://api.github.com/repos/octocat/Hello-World/issues/349",
      "project_url": "https://api.github.com/projects/11223344",
      "column_url": "https://api.github.com/projects/columns/1000000"
    }
    
    # Add an issue to a Project (v2)
    $ python cli.py add-item --project-id PVT_kwDOA...wNmO --issue octocat/Hello-World#349 --v2
    {
      "added_item_id": "PVTI_lADOB...zA1" 
    }

    For a classic project, you must specify the column to add the issue card to (--column-id). The CLI fetched the internal issue ID and created a project card (REST API endpoints for Project (classic) cards - GitHub Docs) and the output shows the new card’s details (including a content_url pointing to the issue). For a Projects v2, you provide the project’s node ID and the issue, and the CLI returns the new project item’s ID. (In a real scenario, you might query the project items afterward to get more info if needed. The added_item_id confirms the item was added.)

  • Updating a project: Change project title or other fields.

    # Rename a classic project and close it
    $ python cli.py update --project-id 11223344 --name "Docs Project (Archived)" --state closed
    {
      "id": 11223344,
      "url": "https://api.github.com/projects/11223344",
      "name": "Docs Project (Archived)",
      "body": "Project for docs",
      "number": 3,
      "state": "closed",
      ...
      "updated_at": "2025-03-01T12:00:00Z"
    }
    
    # Update a Project v2's description and make it public
    $ python cli.py update --project-id PVT_kwDOA...wNmO --body "Updated description" --public true --v2
    {
      "id": "PVT_kwDOA...wNmO",
      "title": "Team Tasks Q2",
      "description": "Updated description",
      "public": true
    }

    The first command uses the --state closed option for a classic project, effectively closing the project (setting state to closed) and also updates the name. The second command updates a v2 project’s description and sets it to public. The output shows the new values.

  • Moving an item: Move a card in a classic project, and reorder an item in a v2 project.

    # Move a classic project card to another column
    $ python cli.py move-item --card-id 99999999 --column-id 1000001 --position top
    {
      "message": "Card moved successfully"
    }
    
    # Reorder an item in a Project v2 (move item to top of the project)
    $ python cli.py move-item --project-id PVT_kwDOA...wNmO --item-id PVTI_lADOB...zA1 --v2
    {
      "moved_item_id": "PVTI_lADOB...zA1"
    }

    For the classic project, we moved the card with ID 99999999 to column 1000001 at the top. The API returns no content on a successful move (hence our CLI just prints a success message) (REST API endpoints for Project (classic) cards - GitHub Docs) For the v2 project, we omitted --after-id, so the item was moved to the top position (Mutations - GitHub Docs) The CLI confirms by echoing the item’s ID.

  • Deleting a project: Delete a project (classic and v2).

    $ python cli.py delete --project-id 11223344
    {
      "message": "Project deleted successfully"
    }
    
    $ python cli.py delete --project-id PVT_kwDOA...wNmO --v2
    {
      "message": "Project deleted successfully"
    }

    Deleting a classic project returns a success message if the operation succeeded (HTTP 204 No Content) (REST API endpoints for Project (classic) cards - GitHub Docs) Deleting a v2 project via GraphQL returns an empty object (we interpret success if no error is thrown), so we similarly output a success message.


Note: The above outputs are examples. Actual responses may include additional fields. The important thing is that the CLI provides structured JSON that closely mirrors the API responses or a simplified result for clarity. You can refine the output formatting as needed (for example, filtering only certain fields).

With this CLI tool, you can script and automate GitHub Project management tasks. It is modular and can be extended – for instance, adding support for listing project items, or more complex queries – by leveraging the GitHub API and GraphQL. The tool provides a unified interface to manage both legacy (classic) project boards and the new Projects (v2) all in one place, using official GitHub APIs Enjoy using the GitHub Projects CLI for your project tracking automation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment