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 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.
-
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 withrepo
and/orproject
scopes; for Projects v2, a token with theread:project
andproject
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
orGET /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 orPOST /orgs/{org}/projects
for an org project) by sending the project name and body (description) For Projects v2, it uses the GraphQLcreateProjectV2
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 eithercontent_id
andcontent_type
for issues/PRs, or anote
for notes) (REST API endpoints for Project (classic) cards - GitHub Docs) For Projects v2, adding an item uses the GraphQLaddProjectV2ItemById
mutation, which attaches an issue or PR (identified by its global node ID) to the project (Adding draft issues is also supported via a separateaddProjectV2DraftIssue
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 GraphQLupdateProjectV2
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 GraphQLupdateProjectV2ItemPosition
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 GraphQLdeleteProjectV2
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 thegql
library as a GraphQL client for GitHub’s GraphQL API. This allows robust handling of API requests. (Under the hood,gql
uses requests oraiohttp
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, thegql
client handles query execution elegantly)
Below, we present the implementation of this CLI tool, organized by module, followed by usage examples.
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)
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
callsGET /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 anote
or acontent_id
withcontent_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 usesDELETE /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 tohttps://api.github.com/graphql
. We set up aClient
with aRequestsHTTPTransport
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 thecreateProjectV2
mutation with an ownerId (fetched via a helper query). It sends the project title (and description) and returns the new project’s dataadd_item
: UsesaddProjectV2ItemById
to attach an existing content item (issue/PR) by its GraphQL node ID to the projectupdate_project
: UsesupdateProjectV2
mutation to update fields like title, description, or visibility (public/private)move_item
: UsesupdateProjectV2ItemPosition
to reposition an item. If anafter_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 thedeleteProjectV2
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.
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 internalid
and then create a project card linking to that issue (withcontent_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 acceptingOWNER/REPO#NUMBER
to identify the issue/PR; the CLI uses a GraphQL query to get the global node ID of that content, then calls theaddProjectV2ItemById
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 usespublic
boolean field inupdateProjectV2
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 theupdateProjectV2ItemPosition
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 usingjson.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.
-
Clone the repository or copy the code into a directory, ensuring the files
auth.py
,api.py
, andcli.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. -
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).
-
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) andproject
scopes should suffice. For Projects v2, a fine-grained PAT with access to “Projects” data or the classic PAT withproject
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.) -
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).
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 likestate
, whereas v2 projects have atitle
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 numericid
. In the second,--org my-org-name --v2
creates a Projects v2 under the organization. The returned JSON has the new project’s GraphQLid
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 acontent_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. Theadded_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 column1000001
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!