Prioritize Scheduler #23
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
| name: Prioritize Scheduler | |
| on: | |
| schedule: | |
| - cron: '*/10 * * * *' | |
| workflow_dispatch: | |
| concurrency: | |
| group: fullsend-prioritize-scheduler | |
| cancel-in-progress: true | |
| jobs: | |
| dispatch: | |
| name: Find and dispatch issues for RICE scoring | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| actions: write | |
| contents: read | |
| steps: | |
| - name: Checkout .fullsend repository | |
| uses: actions/checkout@v6 | |
| - name: Generate app token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| client-id: ${{ vars.FULLSEND_FULLSEND_CLIENT_ID }} | |
| private-key: ${{ secrets.FULLSEND_FULLSEND_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| repositories: .fullsend | |
| - 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 | |
| # --- 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 | |
| if [[ -z "${CURSOR}" ]]; then | |
| AFTER_ARG="" | |
| else | |
| AFTER_ARG=", after: \$cursor" | |
| fi | |
| PAGE_JSON=$(gh api graphql -f query=" | |
| query(\$projectId: ID!$([ -n "${CURSOR}" ] && echo ', $cursor: String!')) { | |
| node(id: \$projectId) { | |
| ... on ProjectV2 { | |
| items(first: 100${AFTER_ARG}) { | |
| 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/.*||') | |
| 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." |