Post

Assign numbers/identifiers to your GitHub Project cards

github niche

Assign numbers/identifiers to your GitHub Project cards

Problem statement: When using GitHub project board, created cards do NOT have an intrinsic number/identifier (sure, there is technically an itemId in the URL which consists of 8 digits, but that is not human identifiable and does not exist visually on the cards themselves). They get assigned a number once they become an issue associated with a particular repo. This is problematic if your project involves multiple repos.

This is what it looks like before this tutorial:

This is what it will look like after this tutorial:

To reiterate, this is most relevant to projects with multiple repos.

Prerequisites

  • you need to know your project id, which will look like PVT_XXXXXXXXXX
    • run gh project list, optionally append --limit 1000 if you are in a large org
  • you need to know the new field id (new field being CARD_ID in this case), which will look like PVTF_XXXXXXXXXX
    • run gh project field-list 123 where 123 is your project number which you can find in your project’s URL
  • you need a GitHub PAT with the following scopes: project, read:org, repo

Python script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#!/usr/bin/env python3

import requests
import time
import os

GITHUB_TOKEN = "ghp_XXXXXXXXXX"
PROJECT_ID = "PVT_XXXXXXXXXX"
FIELD_ID = "PVTF_XXXXXXXXXX"

def run_graphql(query, variables=None):
    """Execute a GraphQL query against GitHub API"""

    headers = {
        "Authorization": f"Bearer {GITHUB_TOKEN}",
        "Content-Type": "application/json",
    }

    payload = {"query": query}
    if variables:
        payload["variables"] = variables
    
    response = requests.post(
        "https://api.github.com/graphql",
        headers=headers,
        json=payload
    )
        
    response.raise_for_status()
    return response.json()

def get_all_project_items():
    """Get all items from the project with pagination"""

    all_items = []
    has_next_page = True
    cursor = None
    page = 1
    
    while has_next_page:
        cursor_param = f', after: "{cursor}"' if cursor else ''
        
        query = f"""
        query {{
          node(id: "{PROJECT_ID}") {{
            ... on ProjectV2 {{
              title
              items(first: 100{cursor_param}) {{
                nodes {{
                  id
                  fieldValues(first: 100) {{
                    nodes {{
                      ... on ProjectV2ItemFieldNumberValue {{
                        field {{
                          ... on ProjectV2FieldCommon {{
                            id
                          }}
                        }}
                        number
                      }}
                    }}
                  }}
                }}
                pageInfo {{
                  hasNextPage
                  endCursor
                }}
              }}
            }}
          }}
        }}
        """
        
        r = run_graphql(query)

            
        items = result["data"]["node"]["items"]["nodes"]
        all_items.extend(items)

        page_info = result["data"]["node"]["items"]["pageInfo"]
        has_next_page = page_info["hasNextPage"]
        cursor = page_info["endCursor"]
        
        print(f"Page {page}: Fetched {len(items)} items (total: {len(all_items)})")
        
        if has_next_page:
            time.sleep(0.5)  # Small delay to be nice to GitHub's API
    
    return all_items

def filter_out_items_with_id(all_items):
    """Find items without numbers and assign them"""
      
    # Find highest number and items without numbers
    highest_number = 0
    items_without_id = []
    
    for item in all_items:
        has_number = False
        for field_value in item["fieldValues"]["nodes"]:
            if (field_value and 
                field_value.get("field") and 
                field_value["field"].get("id") == FIELD_ID):
                has_number = True
                if field_value["number"] > highest_number:
                    highest_number = field_value["number"]
        
        if not has_number:
            items_without_id.append(item["id"])
    
    print(f"Found {len(all_items)} total items")
    print(f"Found {len(items_without_id)} items without numbers")
    print(f"Highest existing number: {highest_number}")
    
    return highest_number, items_without_id
    
def assign_id(highest_number, items_without_id):
    """Find a number to each id-less item"""

    for item_id in items_without_id:
        highest_number += + 1.0 # NOTE: this must be a float if you picked 'number'

        mutation = f"""
        mutation {{
          updateProjectV2ItemFieldValue(
            input: {{
              projectId: "{PROJECT_ID}"
              itemId: "{item_id}"
              fieldId: "{FIELD_ID}"
              value: {{ 
                number: {highest_number}
              }}
            }}
          ) {{
            projectV2Item {{
              id
            }}
          }}
        }}
        """
        
        r = run_graphql(mutation)
        time.sleep(0.5)  # Small delay between updates

    print(f"Finished! Assigned numbers to {len(items_without_id)} items")

def main():
    all_items = get_all_project_items()
    highest_number, filtered_items = filter_out_items_with_id(all_items)
    assign_id(highest_number, filtered_items)

if __name__ == "__main__":
    main()

Final remarks

You may include this script as part of a github action that runs as frequently as you want to keep your board up to date!

This post is licensed under CC BY 4.0 by the author.