Prioritize Scheduler #236
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| # 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: | |
| inputs: | |
| wip_limit: | |
| description: "Max number of prioritize jobs to dispatch (overrides PRIORITIZE_WIP_LIMIT variable)" | |
| required: false | |
| type: number | |
| 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: ${{ inputs.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." |