Skip to content

Prioritize Scheduler #214

Prioritize Scheduler

Prioritize Scheduler #214

# lint-workflow-size: max-lines=220
name: Prioritize Scheduler
on:
# TODO: Re-enable once we have a first-class way to enable/disable
# schedule-based agents per org. Until then, keep commented out to avoid
# noisy Action runs in orgs that haven't configured the prioritize agent.
# schedule:
# - cron: '*/10 * * * *'
workflow_dispatch:
concurrency:
group: fullsend-prioritize-scheduler
cancel-in-progress: false
jobs:
dispatch:
name: Find and dispatch issues for RICE scoring
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
contents: read
id-token: write
steps:
- name: Checkout .fullsend repository
uses: actions/checkout@v6
- name: Mint fullsend token
id: app-token
uses: fullsend-ai/fullsend/.github/actions/mint-token@main
with:
role: fullsend
repos: .fullsend
mint_url: ${{ vars.FULLSEND_MINT_URL }}
- name: Find issues and dispatch prioritize runs
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ORG: ${{ github.repository_owner }}
PROJECT_NUMBER: ${{ vars.FULLSEND_PROJECT_NUMBER }}
WIP_LIMIT: ${{ vars.PRIORITIZE_WIP_LIMIT || '5' }}
STALE_THRESHOLD: ${{ vars.PRIORITIZE_STALE_THRESHOLD || '7d' }}
run: |
set -euo pipefail
if [[ -z "${PROJECT_NUMBER}" ]]; then
echo "::notice::FULLSEND_PROJECT_NUMBER is not set; skipping prioritize scheduler."
exit 0
fi
# --- Parse stale threshold into seconds ---
parse_threshold() {
local val="${1%[dh]}"
local unit="${1: -1}"
case "${unit}" in
d) echo $(( val * 86400 )) ;;
h) echo $(( val * 3600 )) ;;
*) echo "ERROR: unsupported threshold unit '${unit}' (use Nd or Nh)" >&2; exit 1 ;;
esac
}
THRESHOLD_SECONDS=$(parse_threshold "${STALE_THRESHOLD}")
# --- Fetch project metadata ---
PROJECT_ID=$(gh project view "${PROJECT_NUMBER}" --owner "${ORG}" --format json | jq -r '.id')
SCORE_FIELD_ID=$(gh project field-list "${PROJECT_NUMBER}" --owner "${ORG}" --format json \
| jq -r '.fields[] | select(.name == "RICE Score") | .id')
if [[ -z "${SCORE_FIELD_ID}" ]]; then
echo "ERROR: 'RICE Score' field not found on project ${PROJECT_NUMBER}."
echo "Run scripts/setup-prioritize.sh first."
exit 1
fi
# --- Paginate through all project items ---
ITEMS_JSON='{"data":{"node":{"items":{"nodes":[]}}}}'
HAS_NEXT_PAGE=true
CURSOR=""
while [[ "${HAS_NEXT_PAGE}" == "true" ]]; do
# $cursor is declared as nullable (String, not String!) so the
# same query works for the first page (no cursor arg → null)
# and subsequent pages (cursor arg provided).
PAGE_JSON=$(gh api graphql -f query='
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldNumberValue {
field { ... on ProjectV2Field { id name } }
number
updatedAt
}
}
}
content {
... on Issue {
url
number
state
}
}
}
}
}
}
}
' -f projectId="${PROJECT_ID}" ${CURSOR:+-f cursor="${CURSOR}"})
ITEMS_JSON=$(jq -s '
.[0].data.node.items.nodes += .[1].data.node.items.nodes
| .[0]
' <(echo "${ITEMS_JSON}") <(echo "${PAGE_JSON}"))
HAS_NEXT_PAGE=$(echo "${PAGE_JSON}" | jq -r '.data.node.items.pageInfo.hasNextPage')
CURSOR=$(echo "${PAGE_JSON}" | jq -r '.data.node.items.pageInfo.endCursor')
done
TOTAL=$(echo "${ITEMS_JSON}" | jq '.data.node.items.nodes | length')
echo "Fetched ${TOTAL} project items."
# --- Find unscored open issues (up to WIP_LIMIT) ---
UNSCORED_ISSUES=$(echo "${ITEMS_JSON}" | jq -r --arg fid "${SCORE_FIELD_ID}" --argjson limit "${WIP_LIMIT}" '
[.data.node.items.nodes[]
| select(.content.state == "OPEN")
| select(.content.url != null)
| select(
[.fieldValues.nodes[]
| select(.field.id == $fid)
] | length == 0
)
| {url: .content.url, number: .content.number}
] | .[:$limit]
')
UNSCORED_COUNT=$(echo "${UNSCORED_ISSUES}" | jq 'length')
if [[ "${UNSCORED_COUNT}" -gt 0 ]]; then
echo "Found ${UNSCORED_COUNT} unscored issue(s) to dispatch."
else
echo "All issues scored. Checking for stale scores..."
NOW_EPOCH=$(date +%s)
UNSCORED_ISSUES=$(echo "${ITEMS_JSON}" | jq -r --arg fid "${SCORE_FIELD_ID}" --argjson limit "${WIP_LIMIT}" --argjson threshold "${THRESHOLD_SECONDS}" --argjson now "${NOW_EPOCH}" '
[.data.node.items.nodes[]
| select(.content.state == "OPEN")
| select(.content.url != null)
| {
url: .content.url,
number: .content.number,
updatedAt: ([.fieldValues.nodes[] | select(.field.id == $fid) | .updatedAt] | first)
}
| select(.updatedAt != null)
| select(($now - (.updatedAt | fromdateiso8601)) > $threshold)
]
| sort_by(.updatedAt)
| .[:$limit]
')
STALE_COUNT=$(echo "${UNSCORED_ISSUES}" | jq 'length')
if [[ "${STALE_COUNT}" -eq 0 ]]; then
echo "No stale scores found. Nothing to do."
exit 0
fi
echo "Found ${STALE_COUNT} stale issue(s) to re-score."
fi
# --- Dispatch prioritize runs ---
DISPATCHED=0
FAILED=0
for row in $(echo "${UNSCORED_ISSUES}" | jq -c '.[]'); do
ISSUE_URL=$(echo "${row}" | jq -r '.url')
ISSUE_NUMBER=$(echo "${row}" | jq -r '.number')
SOURCE_REPO=$(echo "${ISSUE_URL}" | sed 's|https://github.com/||; s|/issues/.*||')
if [[ ! "${SOURCE_REPO}" =~ ^${ORG}/ ]]; then
echo "::warning::Skipping issue from unexpected org: ${SOURCE_REPO}"
continue
fi
EVENT_PAYLOAD=$(jq -n \
--arg url "${ISSUE_URL}" \
--argjson number "${ISSUE_NUMBER}" \
'{issue: {html_url: $url, number: $number}}')
echo "Dispatching prioritize for ${SOURCE_REPO}#${ISSUE_NUMBER}..."
if gh workflow run prioritize.yml \
--repo "${GITHUB_REPOSITORY}" \
-f event_type="schedule" \
-f source_repo="${SOURCE_REPO}" \
-f event_payload="${EVENT_PAYLOAD}"; then
DISPATCHED=$((DISPATCHED + 1))
else
echo "::warning::Failed to dispatch for ${ISSUE_URL}"
FAILED=$((FAILED + 1))
fi
done
echo "Dispatched ${DISPATCHED} prioritize run(s), ${FAILED} failed."