From c889b549b0b450dc423d5c4a7fa61231d81e41f9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 10 May 2026 11:25:51 -0700 Subject: [PATCH] Revert "chore(ci): assign closed items to sprints (#3216)" This reverts commit 16947f23e2f220495dc5e29892497e908f911362. --- .../assign-closed-items-to-sprint.yaml | 537 ------------------ ...gn-closed-items-to-sprint-workflow.test.ts | 111 ---- 2 files changed, 648 deletions(-) delete mode 100644 .github/workflows/assign-closed-items-to-sprint.yaml delete mode 100644 test/assign-closed-items-to-sprint-workflow.test.ts diff --git a/.github/workflows/assign-closed-items-to-sprint.yaml b/.github/workflows/assign-closed-items-to-sprint.yaml deleted file mode 100644 index 29ff2beb9fe..00000000000 --- a/.github/workflows/assign-closed-items-to-sprint.yaml +++ /dev/null @@ -1,537 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: assign-closed-items-to-sprint - -# pull_request_target runs in the base repo context, giving this workflow -# access to the project automation secret for fork PRs. This workflow is safe -# because it only reads GitHub metadata and edits ProjectV2 fields. Do NOT add a -# checkout step or run PR-sourced code here. -on: - issues: - types: [closed] - pull_request_target: - types: [closed] - schedule: - - cron: "43 * * * *" - workflow_dispatch: - inputs: - lookback_days: - description: "Closed-item reconciliation window in days. Use 0 for full history." - required: false - default: "30" - dry_run: - description: "Report intended changes without mutating the project." - required: false - type: boolean - default: true - -permissions: - contents: read - -jobs: - assign-closed-items-to-sprint: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Generate project automation token - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - with: - client-id: ${{ vars.NEMOCLAW_PROJECT_AUTOMATION_APP_CLIENT_ID }} - private-key: ${{ secrets.NEMOCLAW_PROJECT_AUTOMATION_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-organization-projects: write - permission-issues: read - permission-pull-requests: read - - - name: Assign missing Sprint to closed items - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - ISSUE_NODE_ID: ${{ github.event.issue.node_id }} - PR_NODE_ID: ${{ github.event.pull_request.node_id }} - LOOKBACK_DAYS: ${{ github.event_name == 'workflow_dispatch' && inputs.lookback_days || '30' }} - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} - run: | - set -euo pipefail - - PROJECT_OWNER="NVIDIA" - PROJECT_NUMBER="199" - SPRINT_FIELD_NAME="Sprint" - REPO="${GITHUB_REPOSITORY:?}" - REPO_OWNER="${GITHUB_REPOSITORY_OWNER:?}" - REPO_NAME="${REPO#*/}" - - processed=0 - skipped=0 - added=0 - updated=0 - would_add=0 - would_update=0 - - if [[ ! "${LOOKBACK_DAYS:-30}" =~ ^[0-9]+$ ]]; then - echo "::error::lookback_days must be a non-negative integer." - exit 1 - fi - - if [ "${DRY_RUN:-false}" != "true" ] && [ "${DRY_RUN:-false}" != "false" ]; then - echo "::error::dry_run must be true or false." - exit 1 - fi - - cutoff_iso="" - if [ "$LOOKBACK_DAYS" != "0" ]; then - if cutoff_iso="$( - date -u -d "$LOOKBACK_DAYS days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null - )"; then - : - else - cutoff_iso="$(date -u -v-"$LOOKBACK_DAYS"d +"%Y-%m-%dT%H:%M:%SZ")" - fi - fi - - echo "Repository: $REPO" - echo "Project: $PROJECT_OWNER/$PROJECT_NUMBER" - echo "Dry run: $DRY_RUN" - if [ -n "$cutoff_iso" ]; then - echo "Reconciling items closed at or after $cutoff_iso" - else - echo "Reconciling full closed-item history" - fi - - project_json="$(gh api graphql \ - -f org="$PROJECT_OWNER" \ - -F number="$PROJECT_NUMBER" \ - -f query=' - query($org: String!, $number: Int!) { - organization(login: $org) { - projectV2(number: $number) { - id - fields(first: 100) { - nodes { - __typename - ... on ProjectV2FieldCommon { - id - name - dataType - } - ... on ProjectV2IterationField { - configuration { - iterations { - id - title - startDate - duration - } - completedIterations { - id - title - startDate - duration - } - } - } - } - } - } - } - }')" - - project_id="$(jq -r '.data.organization.projectV2.id // empty' <<<"$project_json")" - if [ -z "$project_id" ]; then - echo "::error::Could not resolve project $PROJECT_OWNER/$PROJECT_NUMBER." - exit 1 - fi - - sprint_field_json="$(jq -c \ - --arg name "$SPRINT_FIELD_NAME" \ - '.data.organization.projectV2.fields.nodes[]? | select(.name == $name and .dataType == "ITERATION")' \ - <<<"$project_json" | head -n 1)" - sprint_field_id="$(jq -r '.id // empty' <<<"$sprint_field_json")" - if [ -z "$sprint_field_id" ]; then - echo "::error::Could not resolve '$SPRINT_FIELD_NAME' iteration field in project $PROJECT_OWNER/$PROJECT_NUMBER." - exit 1 - fi - - iterations_json="$(jq -c \ - --arg name "$SPRINT_FIELD_NAME" \ - '[ - .data.organization.projectV2.fields.nodes[]? - | select(.name == $name and .dataType == "ITERATION") - | (.configuration.completedIterations[]?, .configuration.iterations[]?) - ] | unique_by(.id) | sort_by(.startDate)' \ - <<<"$project_json")" - iteration_count="$(jq -r 'length' <<<"$iterations_json")" - if [ "$iteration_count" = "0" ]; then - echo "::error::Project $PROJECT_OWNER/$PROJECT_NUMBER has no configured '$SPRINT_FIELD_NAME' iterations." - exit 1 - fi - - select_target_iteration() { - local closed_at="$1" - - jq -c --arg closed "$closed_at" ' - def epoch_date($d): (($d[0:10]) + "T00:00:00Z" | fromdateiso8601); - - (epoch_date($closed)) as $closed_epoch - | ( - map( - . + { - startEpoch: epoch_date(.startDate), - endEpoch: (epoch_date(.startDate) + ((.duration | tonumber) * 86400)) - } - ) - | sort_by(.startEpoch) - ) as $sorted - | ($sorted | map(select(.startEpoch <= $closed_epoch and $closed_epoch < .endEpoch)) | last) as $active - | ($sorted | map(select(.startEpoch <= $closed_epoch)) | last) as $prior - | ($sorted | first) as $earliest - | if $active then - $active + { selectionReason: "active" } - elif $prior then - $prior + { selectionReason: "prior" } - else - $earliest + { selectionReason: "earliest" } - end - ' <<<"$iterations_json" - } - - fetch_content() { - local content_id="$1" - - gh api graphql \ - -f id="$content_id" \ - -f query=' - query($id: ID!) { - node(id: $id) { - __typename - ... on Issue { - id - number - closed - closedAt - repository { - nameWithOwner - } - projectItems(first: 100) { - nodes { - ...ProjectItemFields - } - } - } - ... on PullRequest { - id - number - closed - closedAt - mergedAt - repository { - nameWithOwner - } - projectItems(first: 100) { - nodes { - ...ProjectItemFields - } - } - } - } - } - - fragment ProjectItemFields on ProjectV2Item { - id - project { - id - number - } - fieldValues(first: 100) { - nodes { - __typename - ... on ProjectV2ItemFieldIterationValue { - iterationId - title - field { - ... on ProjectV2IterationField { - id - name - } - } - } - } - } - }' - } - - add_to_project() { - local content_id="$1" - - gh api graphql \ - -f project="$project_id" \ - -f content="$content_id" \ - --jq '.data.addProjectV2ItemById.item.id' \ - -f query=' - mutation($project: ID!, $content: ID!) { - addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { - item { - id - } - } - }' - } - - set_sprint() { - local item_id="$1" - local iteration_id="$2" - - gh api graphql \ - -f project="$project_id" \ - -f item="$item_id" \ - -f field="$sprint_field_id" \ - -f iteration="$iteration_id" \ - --silent \ - -f query=' - mutation($project: ID!, $item: ID!, $field: ID!, $iteration: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $project - itemId: $item - fieldId: $field - value: { iterationId: $iteration } - } - ) { - projectV2Item { - id - } - } - }' - } - - process_content() { - local content_id="$1" - local content_json - local content_type - local content_number - local closed - local closed_at - local repo_name - local project_item_json - local project_item_id - local existing_sprint - local target_json - local target_id - local target_title - local target_reason - - content_json="$(fetch_content "$content_id")" - content_type="$(jq -r '.data.node.__typename // empty' <<<"$content_json")" - content_number="$(jq -r '.data.node.number // empty' <<<"$content_json")" - closed="$(jq -r '.data.node.closed // false' <<<"$content_json")" - repo_name="$(jq -r '.data.node.repository.nameWithOwner // empty' <<<"$content_json")" - - if [ "$content_type" != "Issue" ] && [ "$content_type" != "PullRequest" ]; then - echo "::notice::Skipping unsupported content node $content_id ($content_type)." - skipped=$((skipped + 1)) - return - fi - - if [ "$repo_name" != "$REPO" ]; then - echo "::notice::Skipping $content_type #$content_number from $repo_name; expected $REPO." - skipped=$((skipped + 1)) - return - fi - - if [ "$closed" != "true" ]; then - echo "Skipping $content_type #$content_number because it is not closed." - skipped=$((skipped + 1)) - return - fi - - closed_at="$(jq -r ' - if .data.node.__typename == "PullRequest" then - .data.node.mergedAt // .data.node.closedAt // empty - else - .data.node.closedAt // empty - end - ' <<<"$content_json")" - - if [ -z "$closed_at" ]; then - echo "::notice::Skipping $content_type #$content_number because closedAt could not be resolved." - skipped=$((skipped + 1)) - return - fi - - target_json="$(select_target_iteration "$closed_at")" - target_id="$(jq -r '.id // empty' <<<"$target_json")" - target_title="$(jq -r '.title // empty' <<<"$target_json")" - target_reason="$(jq -r '.selectionReason // empty' <<<"$target_json")" - - if [ -z "$target_id" ]; then - echo "::error::Could not select a target Sprint for $content_type #$content_number." - exit 1 - fi - - if [ "$target_reason" = "earliest" ]; then - echo "::warning::$content_type #$content_number closed before all configured Sprints; using earliest Sprint '$target_title'." - fi - - project_item_json="$(jq -c \ - --arg project "$project_id" \ - '.data.node.projectItems.nodes[]? | select(.project.id == $project)' \ - <<<"$content_json" | head -n 1)" - - if [ -n "$project_item_json" ]; then - project_item_id="$(jq -r '.id // empty' <<<"$project_item_json")" - existing_sprint="$(jq -r \ - --arg field "$sprint_field_id" \ - --arg name "$SPRINT_FIELD_NAME" \ - '.fieldValues.nodes[]? - | select(.__typename == "ProjectV2ItemFieldIterationValue") - | select((.field.id // "") == $field or (.field.name // "") == $name) - | .iterationId // empty' \ - <<<"$project_item_json" | head -n 1)" - - if [ -n "$existing_sprint" ]; then - echo "Skipping $content_type #$content_number; Sprint is already set." - skipped=$((skipped + 1)) - return - fi - elif [ "$DRY_RUN" = "true" ]; then - project_item_id="" - echo "::notice::Would add $content_type #$content_number to project $PROJECT_NUMBER." - would_add=$((would_add + 1)) - else - project_item_id="$(add_to_project "$content_id")" - if [ -z "$project_item_id" ]; then - echo "::error::Failed to add $content_type #$content_number to project $PROJECT_NUMBER." - exit 1 - fi - echo "Added $content_type #$content_number to project $PROJECT_NUMBER." - added=$((added + 1)) - fi - - if [ "$DRY_RUN" = "true" ]; then - echo "::notice::Would set $content_type #$content_number Sprint to '$target_title' ($target_id)." - would_update=$((would_update + 1)) - else - set_sprint "$project_item_id" "$target_id" - echo "Set $content_type #$content_number Sprint to '$target_title' ($target_id)." - updated=$((updated + 1)) - fi - } - - collect_closed_ids() { - local kind="$1" - local cursor="" - local connection - local query - local page_json - local has_next - local end_cursor - local oldest_updated - - case "$kind" in - issues) - connection="issues" - query=' - query($owner: String!, $name: String!, $cursor: String) { - repository(owner: $owner, name: $name) { - issues(first: 100, states: CLOSED, after: $cursor, orderBy: { field: UPDATED_AT, direction: DESC }) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - closedAt - updatedAt - } - } - } - }' - ;; - pullRequests) - connection="pullRequests" - query=' - query($owner: String!, $name: String!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: 100, states: CLOSED, after: $cursor, orderBy: { field: UPDATED_AT, direction: DESC }) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - closedAt - updatedAt - } - } - } - }' - ;; - *) - echo "::error::Unsupported collection kind '$kind'." - exit 1 - ;; - esac - - while :; do - args=(-f owner="$REPO_OWNER" -f name="$REPO_NAME" -f query="$query") - if [ -n "$cursor" ]; then - args+=(-f cursor="$cursor") - else - args+=(-F cursor=null) - fi - - page_json="$(gh api graphql "${args[@]}")" - - jq -r \ - --arg connection "$connection" \ - --arg cutoff "$cutoff_iso" \ - '.data.repository[$connection].nodes[]? - | select($cutoff == "" or ((.closedAt // "") >= $cutoff)) - | .id' \ - <<<"$page_json" - - has_next="$(jq -r --arg connection "$connection" '.data.repository[$connection].pageInfo.hasNextPage // false' <<<"$page_json")" - end_cursor="$(jq -r --arg connection "$connection" '.data.repository[$connection].pageInfo.endCursor // empty' <<<"$page_json")" - oldest_updated="$(jq -r --arg connection "$connection" '.data.repository[$connection].nodes[-1].updatedAt // empty' <<<"$page_json")" - - if [ "$has_next" != "true" ] || [ -z "$end_cursor" ]; then - break - fi - - if [ -n "$cutoff_iso" ] && [ -n "$oldest_updated" ] && [[ "$oldest_updated" < "$cutoff_iso" ]]; then - break - fi - - cursor="$end_cursor" - done - } - - if [ -n "${ISSUE_NODE_ID:-}" ]; then - process_content "$ISSUE_NODE_ID" - processed=$((processed + 1)) - elif [ -n "${PR_NODE_ID:-}" ]; then - process_content "$PR_NODE_ID" - processed=$((processed + 1)) - else - echo "Running scheduled/manual reconciliation." - while IFS= read -r content_id; do - [ -n "$content_id" ] || continue - process_content "$content_id" - processed=$((processed + 1)) - done < <(collect_closed_ids issues) - - while IFS= read -r content_id; do - [ -n "$content_id" ] || continue - process_content "$content_id" - processed=$((processed + 1)) - done < <(collect_closed_ids pullRequests) - fi - - echo "Processed: $processed" - echo "Skipped: $skipped" - echo "Added: $added" - echo "Updated: $updated" - echo "Would add: $would_add" - echo "Would update: $would_update" diff --git a/test/assign-closed-items-to-sprint-workflow.test.ts b/test/assign-closed-items-to-sprint-workflow.test.ts deleted file mode 100644 index dd2ea87f468..00000000000 --- a/test/assign-closed-items-to-sprint-workflow.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import YAML from "yaml"; - -const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const WORKFLOW_PATH = ".github/workflows/assign-closed-items-to-sprint.yaml"; -const APP_TOKEN_SHA = "1b10c78c7865c340bc4f6099eb2f838309f1e8c3"; - -type Workflow = { - on?: { - issues?: { types?: string[] }; - pull_request_target?: { types?: string[] }; - schedule?: Array<{ cron?: string }>; - workflow_dispatch?: { - inputs?: Record; - }; - }; - permissions?: Record; - jobs?: Record< - string, - { - steps?: Array<{ name?: string; uses?: string; run?: string }>; - } - >; -}; - -function workflowText(): string { - return readFileSync(join(REPO_ROOT, WORKFLOW_PATH), "utf-8"); -} - -function loadWorkflow(): Workflow { - return YAML.parse(workflowText()) as Workflow; -} - -function steps( - workflow: Workflow, -): Array<{ name?: string; uses?: string; run?: string }> { - const jobs = workflow.jobs ?? {}; - return Object.values(jobs).flatMap((job) => job.steps ?? []); -} - -describe("closed-item Sprint assignment workflow", () => { - it("runs on closed issues, closed pull requests, schedule, and manual dispatch", () => { - const workflow = loadWorkflow(); - - expect(workflow.on?.issues?.types).toEqual(["closed"]); - expect(workflow.on?.pull_request_target?.types).toEqual(["closed"]); - expect(workflow.on?.schedule).toEqual([{ cron: "43 * * * *" }]); - - const inputs = workflow.on?.workflow_dispatch?.inputs ?? {}; - expect(inputs.lookback_days?.default).toBe("30"); - expect(inputs.dry_run?.type).toBe("boolean"); - expect(inputs.dry_run?.default).toBe(true); - }); - - it("uses explicit minimal GITHUB_TOKEN permissions", () => { - const workflow = loadWorkflow(); - - expect(workflow.permissions).toEqual({ contents: "read" }); - }); - - it("uses a pinned GitHub App token action instead of actions/add-to-project", () => { - const raw = workflowText(); - const allSteps = steps(loadWorkflow()); - - expect(raw).not.toContain("actions/add-to-project"); - expect( - allSteps.some( - (step) => step.uses === `actions/create-github-app-token@${APP_TOKEN_SHA}`, - ), - ).toBe(true); - }); - - it("does not check out repository or pull request code", () => { - const allSteps = steps(loadWorkflow()); - - expect( - allSteps.filter((step) => step.uses?.startsWith("actions/checkout@")), - ).toEqual([]); - }); - - it("does not interpolate issue or pull request titles and bodies into shell", () => { - const raw = workflowText(); - - expect(raw).not.toMatch( - /\$\{\{\s*github\.event\.(issue|pull_request)\.(title|body)\s*}}/, - ); - expect(raw).not.toMatch(/\b(issue|pull_request)[_-](title|body)\b/i); - }); - - it("has bash-valid embedded shell", () => { - const assignStep = steps(loadWorkflow()).find( - (step) => step.name === "Assign missing Sprint to closed items", - ); - - expect(assignStep?.run).toBeTruthy(); - - const result = spawnSync("bash", ["-n"], { - input: assignStep?.run ?? "", - encoding: "utf-8", - }); - - expect(result.status, result.stderr).toBe(0); - }); -});