Sending Project Todo Items into Your Discord Channel
The following article provides both the working Github actions and the recommended Secrets to iterate over the open projects and send those To Do items over to a Discord Channel.
As a company we make daily use of Discord in order to co-ordinate brief conversations and updates and whilst there are many solutions to project management and development the reality is your developers are working local to issues within the repository; so why add extra steps to get to the same outcomes ?
Using a workflow to send Projects Todos into Discord
To use this workflow you will require a Personal Action Token whose scope is set to allow Read access to projects and repositories. The User in question should already be able to see project listed in those Repositories within the Organiztion.
**We found working with a Personal Access Token from within the Organisation was an effective way of keeping all the projects in one view **
You will also need to create a Channel Webhook within Discord in order for your Github Action to post the content over to the channel.
Differences between the object structure of issues and draft issues
Whilst assembling this project we noticed that whilst Items was returning values these were more often associated with active Issues ; we had to modify some of the returned filters in order to access content located as Drafts; which is detailed within the second query in the workflow gets nodes containing fieldValues and content. The fieldValues nodes contain ProjectV2ItemFieldTextValue, the text value of which was used to get the title for the Issues. Draft issues titles were pulled from the item.content.title. The map that does this tries to find this title value first, if it does not exist then it will try to find the title field in item.fieldValues.nodes. A fallback "Draft" is in place in case there is an issue that does not include a title anywhere in the object.
- Issues: Title is extracted from
item.content.title. - Draft Issues: Title is pulled from
item.fieldValues.nodesif not present initem.content.title. - Fallback: If no title exists, "Draft" is used.
GH_TOKEN Secret
The workflow included the environment variable github-token, which is set with secrets.GHTOKEN. It was previously set to GITHUB_TOKEN, but according to https://docs.github.com/en/codespaces/managing-codespaces-for-your-organization/managing-development-environment-secrets-for-your-repository-or-organization#naming-secrets, secrets must not be prefixed with "GITHUB". If you encounter an error message which seems vague or unclear we recommend double checking to ensure your GH_TOKEN is configured. It would be ideal if github actions reported on issues with secrets that are being requested but not set within the environment.
Adding Secrets to a GitHub Repository
- Go to your repository in GitHub.
- Open Settings.
- Select Secrets and variables > Actions.
- Click New repository secret.
- Enter the secret name and value (e.g.,
GH_TOKEN,WEBHOOK_DISCORD_CLOUDFLARE_PROJECTS). - Click Add secret.
Secrets can be referenced in workflows as ${{ secrets.SECRET_NAME }} and are not exposed in logs.
list_projects.yml
name: Send Projects Todos into Discord
on:
workflow_dispatch:
schedule:
- cron: '30 8 * * 1-5'
- cron: '0 14 * * 1-5'
jobs:
sendtodos:
runs-on: ubuntu-latest
permissions:
# These permissions are required to access project and issue data
contents: read
issues: read
repository-projects: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get TODOs from GitHub Projects
id: get_todos
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
// This script retrieves all "To Do" items from GitHub Projects v2
// associated with the repository and formats them into a JSON object.
const owner = context.repo.owner;
const repo = context.repo.repo;
let projectItems = {};
// 1. First, get the project IDs and the IDs for their "Status" fields and "To Do" options.
console.log(owner, repo)
const projectsQuery = `
query($owner: String!) {
organization(login: $owner ) {
projectsV2(first: 100) {
nodes {
id
title
fields(first: 100) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
}
`;
const variables = {
owner: context.repo.owner,
name: context.repo.repo
}
const response = await github.graphql(projectsQuery, variables);
const allProjects = response.organization.projectsV2.nodes;
// 2. Iterate through each project to find the "To Do" items.
for (const project of allProjects) {
// Find the 'Status' field
const statusField = project.fields.nodes.find(field => field.name === 'Status');
if (!statusField) continue;
// Find the 'To Do' option within the 'Status' field
const todoOption = statusField.options.find(option => option.name === 'Todo');
if (!todoOption) continue;
// 3. Query for items filtered by the 'To Do' status.
const projectItemsQuery = `
query($projectId: ID!) {
node(id: $projectId ) {
... on ProjectV2 {
url
items(first: 100, orderBy: {field: POSITION, direction: ASC}) {
nodes{
id
fieldValues(first: 80) {
nodes{
... on ProjectV2ItemFieldTextValue {
text
field {
... on ProjectV2FieldCommon {
name
}
}
}
... on ProjectV2ItemFieldDateValue {
date
field {
... on ProjectV2FieldCommon {
name
}
}
}
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2FieldCommon {
name
}
}
}
}
}
content{
... on DraftIssue {
title
body
}
...on Issue {
title
url
}
...on PullRequest {
title
url
assignees(first: 100) {
nodes{
login
}
}
}
}
}
}
}
}
}
`;
const itemsVariables = {
projectId: project.id,
statusFieldId: statusField.id,
todoOptionId: todoOption.id
}
const { node } = await github.graphql(projectItemsQuery, itemsVariables);
if (node) {
projectItems[project.title] = {
url: node.url,
items: node.items.nodes
.filter(item => item)
}
}
}
let splitData = {}
for (const [project, items] of Object.entries(projectItems)) {
const projectUrl = items.url
const todos = items.items
.filter(item => item.fieldValues.nodes.some(
node => node.name === "Todo" || (node.field && node.field.name === "Status" && node.name === "Todo")
))
.map(item => ({
title: item?.content?.title ?? item?.fieldValues?.nodes?.find(n => n.field?.name?.toLowerCase() === "title")?.text ?? "Draft",
url: item?.content?.url ?? items.url
}))
if (todos) {
splitData[project] = {
url: projectUrl,
todos: todos
}
}
}
// Export the formatted JSON to a step output for the next step to use.
const output = splitData
core.setOutput('todo_json', output);
- name: Send TODO list to Discord
if: success() && steps.get_todos.outputs.todo_json != '{}'
env:
WEBHOOK_DISCORD_CLOUDFLARE_PROJECTS: ${{ secrets.WEBHOOK_DISCORD_CLOUDFLARE_PROJECTS }}
TODO_JSON: ${{ steps.get_todos.outputs.todo_json }}
run: |
# Use jq to create a rich JSON payload with embeds for Discord.
DISCORD_PAYLOAD=$(jq -n \
--arg content "Daily Project TODOs Check-in" \
--arg todos "$TODO_JSON" \
'{
"content": $content,
"embeds": [
{
"title": "Current TODOs",
"description": "Here is a list of all items in the To Do column across your repository projects:",
"color": 3447003, # A nice Discord blue
"fields": ($todos | fromjson | to_entries | map({
name: ("[" + .key + "](" + .value.url + ")"),
value: (.value.todos | map("• " + .title) | join("\n")),
inline: false
}))
}
]
}')
# Send the formatted JSON payload to the Discord webhook URL.
curl -H "Content-Type: application/json" \
-d "$DISCORD_PAYLOAD" \
$WEBHOOK_DISCORD_CLOUDFLARE_PROJECTS
Breakdown of the workflow list_projects.yml
Workflow Triggers
- Manual Trigger: You can run the workflow manually via the GitHub Actions UI (
workflow_dispatch). - Scheduled Runs: The workflow crons are scheduled to run twice daily, Monday through Friday:
- 8:30 AM UTC
- 2:00 PM UTC
Job: sendtodos
Environment
- Runner:
ubuntu-latest - Permissions: Read access to repository contents, issues, and projects.
Steps
Checkout Repository
- Uses
actions/checkout@v4to access project files.
- Uses
Get TODOs from GitHub Projects
- Uses
actions/github-script@v7to:- Query all organization-level GitHub Projects (v2)
- Find "Status" fields and "Todo" options
- Retrieve items in "Todo" status
- Format results as JSON (
todo_json)
- Uses
Send TODO List to Discord
- If Todos exist:
- Formats JSON into a Discord embed using
jq - Sends payload to Discord webhook via
curl - Embed includes project names (as links) and bulleted Todo lists
- Formats JSON into a Discord embed using
- If Todos exist:
Required Secrets
GH_TOKEN: GitHub token for Projects/Issues accessWEBHOOK_DISCORD_CLOUDFLARE_PROJECTS: Discord webhook URL
Example Discord Output
Current TODOs
Here is a list of all items in the To Do column across your repository projects:
- [Project Name](project-url)
- • Task 1
- • Task 2
Notes
- Draft Issues vs. Issues: Titles are pulled from either the issue or draft issue object, with a fallback to "Draft" if missing.
- Secret Naming: Do not prefix secrets with
GITHUB_(see GitHub Docs). - Adding Secrets: Go to your repo’s Settings → Secrets and variables → Actions → New repository secret.