diff --git a/.github/workflows/add-to-project.yaml b/.github/workflows/add-to-project.yaml index 497157ac..6504ef42 100644 --- a/.github/workflows/add-to-project.yaml +++ b/.github/workflows/add-to-project.yaml @@ -22,30 +22,6 @@ on: types: - labeled - ready_for_review - schedule: - - cron: "0 0 * * *" # Run daily at midnight - workflow_dispatch: - inputs: - dry-run: - description: "Enable dry-run mode for stale issue detection (no actual changes)" - required: false - default: false - type: boolean - stale-days: - description: "Number of days before an issue is considered stale" - required: false - default: 60 - type: number - close-after-days: - description: "Number of days after marking stale before closing" - required: false - default: 7 - type: number - skip-label: - description: "Label to skip stale processing (issues with this label won't be marked stale or closed)" - required: false - default: "keep" - type: string workflow_call: inputs: labeled: @@ -62,26 +38,6 @@ on: reviewers-individuals: required: false type: string - dry-run: - description: "Enable dry-run mode for stale issue detection (no actual changes)" - required: false - default: false - type: boolean - stale-days: - description: "Number of days before an issue is considered stale" - required: false - default: 60 - type: number - close-after-days: - description: "Number of days after marking stale before closing" - required: false - default: 7 - type: number - skip-label: - description: "Label to skip stale processing (issues with this label won't be marked stale or closed)" - required: false - default: "keep" - type: string jobs: add-to-project: if: github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'workflow_call' @@ -150,408 +106,3 @@ jobs: persons: ${{ inputs.reviewers-individuals }} # add individual persons here include-draft: false # Draft PRs will be skipped (default: false) skip-with-manual-reviewers: 1 # Skip this action, if the number of reviwers was already assigned (default: 0) - - manage-stale-issues: - name: Manage Stale Unplanned Issues - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' - permissions: - issues: write - repository-projects: read - steps: - - uses: actions/create-github-app-token@v2 - id: generate-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_SECRET }} - - - name: Manage Stale Issues - env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} - OWNER: ${{ github.repository_owner }} - REPO: ${{ github.event.repository.name }} - STALE_DAYS: ${{ inputs.stale-days || 60 }} - CLOSE_AFTER_DAYS: ${{ inputs.close-after-days || 7 }} - SKIP_LABEL: ${{ inputs.skip-label || 'keep' }} - DRY_RUN: ${{ inputs.dry-run || 'false' }} - run: | - # Calculate the date based on STALE_DAYS - STALE_DATE=$(date -u -d "${STALE_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-${STALE_DAYS}d +%Y-%m-%dT%H:%M:%SZ) - - if [ "$DRY_RUN" = "true" ]; then - echo "🔍 DRY-RUN MODE ENABLED - No changes will be made" - echo "═══════════════════════════════════════════════════" - fi - - echo "Looking for issues older than $STALE_DATE (${STALE_DAYS} days ago)..." - echo "Issues will be closed ${CLOSE_AFTER_DAYS} days after being marked stale..." - - # Calculate close threshold once (used for all stale issues) - CLOSE_THRESHOLD=$(date -d "${CLOSE_AFTER_DAYS} days ago" +%s 2>/dev/null || date -v-${CLOSE_AFTER_DAYS}d +%s) - - # Query to get all open issues with their project data - CURSOR="" - while :; do - if [ -z "$CURSOR" ]; then - QUERY=' - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - issues(first: 100, states: OPEN) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - title - createdAt - updatedAt - labels(first: 20) { - nodes { name } - } - projectItems(first: 5) { - nodes { - id - iteration: fieldValueByName(name: "Iteration") { - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - } - } - status: fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - } - ' - else - QUERY=' - query($owner: String!, $repo: String!, $cursor: String!) { - repository(owner: $owner, name: $repo) { - issues(first: 100, states: OPEN, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - title - createdAt - updatedAt - labels(first: 20) { - nodes { name } - } - projectItems(first: 5) { - nodes { - id - iteration: fieldValueByName(name: "Iteration") { - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - } - } - status: fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - } - ' - fi - - if [ -z "$CURSOR" ]; then - PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f query="$QUERY") - else - PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f cursor="$CURSOR" -f query="$QUERY") - fi - - # Process issues from this page - echo "$PAGE" | jq -c '.data.repository.issues.nodes[]' | while read -r issue; do - NUMBER=$(echo "$issue" | jq -r '.number') - TITLE=$(echo "$issue" | jq -r '.title') - CREATED_AT=$(echo "$issue" | jq -r '.createdAt') - UPDATED_AT=$(echo "$issue" | jq -r '.updatedAt') # updatedAt changes on comments, label changes (including from this workflow), edits, etc., so bot actions can reset staleness. - - # Check if issue has skip label - if so, skip all stale processing - HAS_SKIP_LABEL=$(echo "$issue" | jq -r --arg SKIP_LABEL "$SKIP_LABEL" '.labels.nodes[].name | select(. == $SKIP_LABEL)') - if [ -n "$HAS_SKIP_LABEL" ]; then - echo "Issue #$NUMBER: Has skip label '$SKIP_LABEL' - skipping stale processing" - continue - fi - - # Check if issue already has stale label - HAS_STALE_LABEL=$(echo "$issue" | jq -r '.labels.nodes[].name | select(. == "stale")') - - # Check timestamps - CREATED_TIMESTAMP=$(date -d "$CREATED_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$CREATED_AT" +%s) - UPDATED_TIMESTAMP=$(date -d "$UPDATED_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$UPDATED_AT" +%s) - STALE_TIMESTAMP=$(date -d "$STALE_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STALE_DATE" +%s) - - # If issue has stale label, check if a plan was added or if it should be closed - if [ -n "$HAS_STALE_LABEL" ]; then - # First, check if issue now has a current or future iteration (was planned meanwhile) - HAS_PLANNED_ITERATION=false - CURRENT_DATE=${CURRENT_DATE:-$(date -u +%Y-%m-%d)} - - # Use process substitution to avoid subshell issues - while read -r projectItem; do - ITERATION_TITLE=$(echo "$projectItem" | jq -r '.iteration.title // empty') - ITERATION_START=$(echo "$projectItem" | jq -r '.iteration.startDate // empty') - ITERATION_DURATION=$(echo "$projectItem" | jq -r '.iteration.duration // empty') - - if [ -n "$ITERATION_START" ] && [ -n "$ITERATION_DURATION" ]; then - # Calculate iteration end date - ITERATION_END=$(date -d "$ITERATION_START + $ITERATION_DURATION days" +%Y-%m-%d 2>/dev/null || date -j -v+${ITERATION_DURATION}d -f "%Y-%m-%d" "$ITERATION_START" +%Y-%m-%d) - - # Check if iteration is current or future - if [[ "$ITERATION_END" > "$CURRENT_DATE" ]] || [[ "$ITERATION_END" == "$CURRENT_DATE" ]]; then - HAS_PLANNED_ITERATION=true - echo "Issue #$NUMBER: Was stale but now planned for iteration '$ITERATION_TITLE' (ends: $ITERATION_END)" - break - fi - fi - done < <(echo "$issue" | jq -c '.projectItems.nodes[]') - - # If issue is now planned, remove stale label - if [ "$HAS_PLANNED_ITERATION" = true ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "[DRY-RUN] Issue #$NUMBER: Would remove stale label (now has planned iteration)" - else - echo "Issue #$NUMBER: Removing stale label (now has planned iteration)" - gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --remove-label "stale" - fi - continue - fi - - # If not planned, check if it should be closed - if [ "$UPDATED_TIMESTAMP" -lt "$CLOSE_THRESHOLD" ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "[DRY-RUN] Issue #$NUMBER: Would close stale issue (no updates in ${CLOSE_AFTER_DAYS}+ days)" - echo "[DRY-RUN] - Would close as 'not planned' with comment" - else - echo "Issue #$NUMBER: Closing stale issue (no updates in ${CLOSE_AFTER_DAYS}+ days)" - gh issue close "$NUMBER" --repo "$OWNER/$REPO" --reason "not planned" --comment "Closing this issue as it has been stale for more than ${CLOSE_AFTER_DAYS} days without updates or being added to an iteration. Feel free to reopen if this is still relevant." - fi - else - echo "Issue #$NUMBER: Has stale label but recently updated (updated: $UPDATED_AT) - will check again later" - fi - continue - fi - - # If issue doesn't have stale label, check if it should be marked as stale - # Check both creation and last update - if either is recent, don't mark as stale - if [ "$CREATED_TIMESTAMP" -gt "$STALE_TIMESTAMP" ]; then - echo "Issue #$NUMBER: Not old enough (created: $CREATED_AT)" - continue - fi - - if [ "$UPDATED_TIMESTAMP" -gt "$STALE_TIMESTAMP" ]; then - echo "Issue #$NUMBER: Recently updated (updated: $UPDATED_AT) - not marking as stale" - continue - fi - - # Check if issue has a current or future iteration - HAS_PLANNED_ITERATION=false - - # Use process substitution to avoid subshell issues - while read -r projectItem; do - ITERATION_TITLE=$(echo "$projectItem" | jq -r '.iteration.title // empty') - ITERATION_START=$(echo "$projectItem" | jq -r '.iteration.startDate // empty') - ITERATION_DURATION=$(echo "$projectItem" | jq -r '.iteration.duration // empty') - - if [ -n "$ITERATION_START" ] && [ -n "$ITERATION_DURATION" ]; then - # Calculate iteration end date - ITERATION_END=$(date -d "$ITERATION_START + $ITERATION_DURATION days" +%Y-%m-%d 2>/dev/null || date -j -v+${ITERATION_DURATION}d -f "%Y-%m-%d" "$ITERATION_START" +%Y-%m-%d) - - # Check if iteration is current or future - if [[ "$ITERATION_END" > "$CURRENT_DATE" ]] || [[ "$ITERATION_END" == "$CURRENT_DATE" ]]; then - HAS_PLANNED_ITERATION=true - echo "Issue #$NUMBER: Planned for iteration '$ITERATION_TITLE' (ends: $ITERATION_END)" - break - fi - fi - done < <(echo "$issue" | jq -c '.projectItems.nodes[]') - - # If old and not planned for current/future iteration, mark as stale - if [ "$HAS_PLANNED_ITERATION" = false ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "[DRY-RUN] Issue #$NUMBER: Would mark as stale (created: $CREATED_AT, no planned iteration)" - echo "[DRY-RUN] - Would add label: stale" - echo "[DRY-RUN] - Would add comment about ${CLOSE_AFTER_DAYS}-day grace period" - else - echo "Issue #$NUMBER: Marking as stale (created: $CREATED_AT, no planned iteration)" - - # Create stale label if it doesn't exist - gh label create "stale" --repo "$OWNER/$REPO" --color "EDEDED" --description "This issue has not been updated in ${STALE_DAYS}+ days and is not planned" || true - - # Add stale label and comment - gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --add-label "stale" - gh issue comment "$NUMBER" --repo "$OWNER/$REPO" --body "This issue has been automatically marked as stale because it has been open for more than ${STALE_DAYS} days without being planned for a current or future iteration. If this issue is still relevant, please update it or add it to an iteration. Otherwise, it will be closed in ${CLOSE_AFTER_DAYS} days." - fi - fi - done - - HAS_NEXT=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.hasNextPage') - if [ "$HAS_NEXT" != "true" ]; then - break - fi - - CURSOR=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.endCursor') - done - - sync-priority: - name: Sync Priority Label - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' - permissions: - issues: write - repository-projects: read - steps: - - uses: actions/create-github-app-token@v2 - id: generate-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_SECRET }} - - - name: Sync Priority to Label - env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} - OWNER: ${{ github.repository_owner }} - REPO: ${{ github.event.repository.name }} - run: | - # Get all open issues with 'security' label (with pagination) - ISSUES="" - CURSOR="" - while :; do - if [ -z "$CURSOR" ]; then - QUERY=' - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - issues(first: 100, states: OPEN, labels: ["security"]) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - labels(first: 20) { - nodes { name } - } - projectItems(first: 5) { - nodes { - fieldValueByName(name: "Priority") { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - } - ' - else - QUERY=' - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - issues(first: 100, states: OPEN, labels: ["security"], after: "'$CURSOR'") { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - labels(first: 20) { - nodes { name } - } - projectItems(first: 5) { - nodes { - fieldValueByName(name: "Priority") { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - } - ' - fi - - PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f query="$QUERY") - ISSUES="${ISSUES}${PAGE}" - - HAS_NEXT=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.hasNextPage') - if [ "$HAS_NEXT" != "true" ]; then - break - fi - - CURSOR=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.endCursor') - done - # Loop through issues - echo "$ISSUES" | jq -c '.data.repository.issues.nodes[]' | while read -r issue; do - NUMBER=$(echo "$issue" | jq -r '.number') - # Get Priority from project items (take the first one found) - RAW_PRIORITY=$(echo "$issue" | jq -r '.projectItems.nodes[].fieldValueByName.name // empty' | head -n 1) - - # Remove non-ASCII characters (icons/emojis) to get clean label name - CLEAN_PRIORITY=$(echo "$RAW_PRIORITY" | tr -cd '\000-\177' | xargs) - - if [ -z "$CLEAN_PRIORITY" ]; then - echo "Issue #$NUMBER: No priority found in project (or empty after cleanup)." - continue - fi - - # Map Priority to P-labels - case "$CLEAN_PRIORITY" in - "Low") - TARGET_LABEL="P3" - LABEL_COLOR="EDEDED" - ;; - "Medium") - TARGET_LABEL="P2" - LABEL_COLOR="FBCA04" - ;; - "High") - TARGET_LABEL="P1" - LABEL_COLOR="FF9F1C" - ;; - "Urgent") - TARGET_LABEL="P0" - LABEL_COLOR="D73A4A" - ;; - *) - TARGET_LABEL="P3" - LABEL_COLOR="EDEDED" - ;; - esac - - # Check if label already exists - HAS_LABEL=$(echo "$issue" | jq -r --arg TARGET_LABEL "$TARGET_LABEL" '.labels.nodes[].name | select(. == $TARGET_LABEL)') - - # Remove other priority labels if they exist - echo "$issue" | jq -r '.labels.nodes[].name' | while read -r EXISTING_LABEL; do - if [[ "$EXISTING_LABEL" =~ ^P[0-9]+$ ]] && [ "$EXISTING_LABEL" != "$TARGET_LABEL" ]; then - echo "Issue #$NUMBER: Removing conflicting label '$EXISTING_LABEL'." - gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --remove-label "$EXISTING_LABEL" - fi - done - - if [ -n "$HAS_LABEL" ]; then - echo "Issue #$NUMBER: Already has label '$TARGET_LABEL'." - else - echo "Issue #$NUMBER: Adding label '$TARGET_LABEL'." - # Create label if it doesn't exist (ignores error if exists) - gh label create "$TARGET_LABEL" --repo "$OWNER/$REPO" --color "$LABEL_COLOR" || true - - gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --add-label "$TARGET_LABEL" - fi - done diff --git a/.github/workflows/manage-issues.yaml b/.github/workflows/manage-issues.yaml new file mode 100644 index 00000000..5b4d6829 --- /dev/null +++ b/.github/workflows/manage-issues.yaml @@ -0,0 +1,459 @@ +# Copyright 2024 Zaphiro Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Manage Issues and PRs +on: + schedule: + - cron: "0 0 * * *" # Run daily at midnight + workflow_dispatch: + inputs: + dry-run: + description: "Enable dry-run mode for stale issue detection (no actual changes)" + required: false + default: false + type: boolean + stale-days: + description: "Number of days before an issue is considered stale" + required: false + default: 60 + type: number + close-after-days: + description: "Number of days after marking stale before closing" + required: false + default: 7 + type: number + skip-label: + description: "Label to skip stale processing (issues with this label won't be marked stale or closed)" + required: false + default: "keep" + type: string + workflow_call: + inputs: + dry-run: + description: "Enable dry-run mode for stale issue detection (no actual changes)" + required: false + default: false + type: boolean + stale-days: + description: "Number of days before an issue is considered stale" + required: false + default: 60 + type: number + close-after-days: + description: "Number of days after marking stale before closing" + required: false + default: 7 + type: number + skip-label: + description: "Label to skip stale processing (issues with this label won't be marked stale or closed)" + required: false + default: "keep" + type: string +jobs: + manage-stale-issues: + name: Manage Stale Unplanned Issues + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_SECRET }} + + - name: Manage Stale Issues + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + STALE_DAYS: ${{ inputs.stale-days || 60 }} + CLOSE_AFTER_DAYS: ${{ inputs.close-after-days || 7 }} + SKIP_LABEL: ${{ inputs.skip-label || 'keep' }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + run: | + # Calculate the date based on STALE_DAYS + STALE_DATE=$(date -u -d "${STALE_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-${STALE_DAYS}d +%Y-%m-%dT%H:%M:%SZ) + + if [ "$DRY_RUN" = "true" ]; then + echo "🔍 DRY-RUN MODE ENABLED - No changes will be made" + echo "═══════════════════════════════════════════════════" + fi + + echo "Looking for issues older than $STALE_DATE (${STALE_DAYS} days ago)..." + echo "Issues will be closed ${CLOSE_AFTER_DAYS} days after being marked stale..." + + # Calculate close threshold once (used for all stale issues) + CLOSE_THRESHOLD=$(date -d "${CLOSE_AFTER_DAYS} days ago" +%s 2>/dev/null || date -v-${CLOSE_AFTER_DAYS}d +%s) + + # Query to get all open issues with their project data + CURSOR="" + while :; do + if [ -z "$CURSOR" ]; then + QUERY=' + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + createdAt + updatedAt + labels(first: 20) { + nodes { name } + } + projectItems(first: 5) { + nodes { + id + iteration: fieldValueByName(name: "Iteration") { + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + } + } + status: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + ' + else + QUERY=' + query($owner: String!, $repo: String!, $cursor: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + createdAt + updatedAt + labels(first: 20) { + nodes { name } + } + projectItems(first: 5) { + nodes { + id + iteration: fieldValueByName(name: "Iteration") { + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + } + } + status: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + ' + fi + + if [ -z "$CURSOR" ]; then + PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f query="$QUERY") + else + PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f cursor="$CURSOR" -f query="$QUERY") + fi + + # Process issues from this page + echo "$PAGE" | jq -c '.data.repository.issues.nodes[]' | while read -r issue; do + NUMBER=$(echo "$issue" | jq -r '.number') + TITLE=$(echo "$issue" | jq -r '.title') + CREATED_AT=$(echo "$issue" | jq -r '.createdAt') + UPDATED_AT=$(echo "$issue" | jq -r '.updatedAt') # updatedAt changes on comments, label changes (including from this workflow), edits, etc., so bot actions can reset staleness. + + # Check if issue has skip label - if so, skip all stale processing + HAS_SKIP_LABEL=$(echo "$issue" | jq -r --arg SKIP_LABEL "$SKIP_LABEL" '.labels.nodes[].name | select(. == $SKIP_LABEL)') + if [ -n "$HAS_SKIP_LABEL" ]; then + echo "Issue #$NUMBER: Has skip label '$SKIP_LABEL' - skipping stale processing" + continue + fi + + # Check if issue already has stale label + HAS_STALE_LABEL=$(echo "$issue" | jq -r '.labels.nodes[].name | select(. == "stale")') + + # Check timestamps + CREATED_TIMESTAMP=$(date -d "$CREATED_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$CREATED_AT" +%s) + UPDATED_TIMESTAMP=$(date -d "$UPDATED_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$UPDATED_AT" +%s) + STALE_TIMESTAMP=$(date -d "$STALE_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STALE_DATE" +%s) + + # If issue has stale label, check if a plan was added or if it should be closed + if [ -n "$HAS_STALE_LABEL" ]; then + # First, check if issue now has a current or future iteration (was planned meanwhile) + HAS_PLANNED_ITERATION=false + CURRENT_DATE=${CURRENT_DATE:-$(date -u +%Y-%m-%d)} + + # Use process substitution to avoid subshell issues + while read -r projectItem; do + ITERATION_TITLE=$(echo "$projectItem" | jq -r '.iteration.title // empty') + ITERATION_START=$(echo "$projectItem" | jq -r '.iteration.startDate // empty') + ITERATION_DURATION=$(echo "$projectItem" | jq -r '.iteration.duration // empty') + + if [ -n "$ITERATION_START" ] && [ -n "$ITERATION_DURATION" ]; then + # Calculate iteration end date + ITERATION_END=$(date -d "$ITERATION_START + $ITERATION_DURATION days" +%Y-%m-%d 2>/dev/null || date -j -v+${ITERATION_DURATION}d -f "%Y-%m-%d" "$ITERATION_START" +%Y-%m-%d) + + # Check if iteration is current or future + if [[ "$ITERATION_END" > "$CURRENT_DATE" ]] || [[ "$ITERATION_END" == "$CURRENT_DATE" ]]; then + HAS_PLANNED_ITERATION=true + echo "Issue #$NUMBER: Was stale but now planned for iteration '$ITERATION_TITLE' (ends: $ITERATION_END)" + break + fi + fi + done < <(echo "$issue" | jq -c '.projectItems.nodes[]') + + # If issue is now planned, remove stale label + if [ "$HAS_PLANNED_ITERATION" = true ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY-RUN] Issue #$NUMBER: Would remove stale label (now has planned iteration)" + else + echo "Issue #$NUMBER: Removing stale label (now has planned iteration)" + gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --remove-label "stale" + fi + continue + fi + + # If not planned, check if it should be closed + if [ "$UPDATED_TIMESTAMP" -lt "$CLOSE_THRESHOLD" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY-RUN] Issue #$NUMBER: Would close stale issue (no updates in ${CLOSE_AFTER_DAYS}+ days)" + echo "[DRY-RUN] - Would close as 'not planned' with comment" + else + echo "Issue #$NUMBER: Closing stale issue (no updates in ${CLOSE_AFTER_DAYS}+ days)" + gh issue close "$NUMBER" --repo "$OWNER/$REPO" --reason "not planned" --comment "Closing this issue as it has been stale for more than ${CLOSE_AFTER_DAYS} days without updates or being added to an iteration. Feel free to reopen if this is still relevant." + fi + else + echo "Issue #$NUMBER: Has stale label but recently updated (updated: $UPDATED_AT) - will check again later" + fi + continue + fi + + # If issue doesn't have stale label, check if it should be marked as stale + # Check both creation and last update - if either is recent, don't mark as stale + if [ "$CREATED_TIMESTAMP" -gt "$STALE_TIMESTAMP" ]; then + echo "Issue #$NUMBER: Not old enough (created: $CREATED_AT)" + continue + fi + + if [ "$UPDATED_TIMESTAMP" -gt "$STALE_TIMESTAMP" ]; then + echo "Issue #$NUMBER: Recently updated (updated: $UPDATED_AT) - not marking as stale" + continue + fi + + # Check if issue has a current or future iteration + HAS_PLANNED_ITERATION=false + + # Use process substitution to avoid subshell issues + while read -r projectItem; do + ITERATION_TITLE=$(echo "$projectItem" | jq -r '.iteration.title // empty') + ITERATION_START=$(echo "$projectItem" | jq -r '.iteration.startDate // empty') + ITERATION_DURATION=$(echo "$projectItem" | jq -r '.iteration.duration // empty') + + if [ -n "$ITERATION_START" ] && [ -n "$ITERATION_DURATION" ]; then + # Calculate iteration end date + ITERATION_END=$(date -d "$ITERATION_START + $ITERATION_DURATION days" +%Y-%m-%d 2>/dev/null || date -j -v+${ITERATION_DURATION}d -f "%Y-%m-%d" "$ITERATION_START" +%Y-%m-%d) + + # Check if iteration is current or future + if [[ "$ITERATION_END" > "$CURRENT_DATE" ]] || [[ "$ITERATION_END" == "$CURRENT_DATE" ]]; then + HAS_PLANNED_ITERATION=true + echo "Issue #$NUMBER: Planned for iteration '$ITERATION_TITLE' (ends: $ITERATION_END)" + break + fi + fi + done < <(echo "$issue" | jq -c '.projectItems.nodes[]') + + # If old and not planned for current/future iteration, mark as stale + if [ "$HAS_PLANNED_ITERATION" = false ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY-RUN] Issue #$NUMBER: Would mark as stale (created: $CREATED_AT, no planned iteration)" + echo "[DRY-RUN] - Would add label: stale" + echo "[DRY-RUN] - Would add comment about ${CLOSE_AFTER_DAYS}-day grace period" + else + echo "Issue #$NUMBER: Marking as stale (created: $CREATED_AT, no planned iteration)" + + # Create stale label if it doesn't exist + gh label create "stale" --repo "$OWNER/$REPO" --color "EDEDED" --description "This issue has not been updated in ${STALE_DAYS}+ days and is not planned" || true + + # Add stale label and comment + gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --add-label "stale" + gh issue comment "$NUMBER" --repo "$OWNER/$REPO" --body "This issue has been automatically marked as stale because it has been open for more than ${STALE_DAYS} days without being planned for a current or future iteration. If this issue is still relevant, please update it or add it to an iteration. Otherwise, it will be closed in ${CLOSE_AFTER_DAYS} days." + fi + fi + done + + HAS_NEXT=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + + CURSOR=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.endCursor') + done + + sync-priority: + name: Sync Priority Label + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_SECRET }} + + - name: Sync Priority to Label + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + run: | + # Get all open issues with 'security' label (with pagination) + ISSUES="" + CURSOR="" + while :; do + if [ -z "$CURSOR" ]; then + QUERY=' + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, labels: ["security"]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + labels(first: 20) { + nodes { name } + } + projectItems(first: 5) { + nodes { + fieldValueByName(name: "Priority") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + ' + else + QUERY=' + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, labels: ["security"], after: "'$CURSOR'") { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + labels(first: 20) { + nodes { name } + } + projectItems(first: 5) { + nodes { + fieldValueByName(name: "Priority") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + ' + fi + + PAGE=$(gh api graphql -f owner="$OWNER" -f repo="$REPO" -f query="$QUERY") + ISSUES="${ISSUES}${PAGE}" + + HAS_NEXT=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + + CURSOR=$(printf '%s\n' "$PAGE" | jq -r '.data.repository.issues.pageInfo.endCursor') + done + # Loop through issues + echo "$ISSUES" | jq -c '.data.repository.issues.nodes[]' | while read -r issue; do + NUMBER=$(echo "$issue" | jq -r '.number') + # Get Priority from project items (take the first one found) + RAW_PRIORITY=$(echo "$issue" | jq -r '.projectItems.nodes[].fieldValueByName.name // empty' | head -n 1) + + # Remove non-ASCII characters (icons/emojis) to get clean label name + CLEAN_PRIORITY=$(echo "$RAW_PRIORITY" | tr -cd '\000-\177' | xargs) + + if [ -z "$CLEAN_PRIORITY" ]; then + echo "Issue #$NUMBER: No priority found in project (or empty after cleanup)." + continue + fi + + # Map Priority to P-labels + case "$CLEAN_PRIORITY" in + "Low") + TARGET_LABEL="P3" + LABEL_COLOR="EDEDED" + ;; + "Medium") + TARGET_LABEL="P2" + LABEL_COLOR="FBCA04" + ;; + "High") + TARGET_LABEL="P1" + LABEL_COLOR="FF9F1C" + ;; + "Urgent") + TARGET_LABEL="P0" + LABEL_COLOR="D73A4A" + ;; + *) + TARGET_LABEL="P3" + LABEL_COLOR="EDEDED" + ;; + esac + + # Check if label already exists + HAS_LABEL=$(echo "$issue" | jq -r --arg TARGET_LABEL "$TARGET_LABEL" '.labels.nodes[].name | select(. == $TARGET_LABEL)') + + # Remove other priority labels if they exist + echo "$issue" | jq -r '.labels.nodes[].name' | while read -r EXISTING_LABEL; do + if [[ "$EXISTING_LABEL" =~ ^P[0-9]+$ ]] && [ "$EXISTING_LABEL" != "$TARGET_LABEL" ]; then + echo "Issue #$NUMBER: Removing conflicting label '$EXISTING_LABEL'." + gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --remove-label "$EXISTING_LABEL" + fi + done + + if [ -n "$HAS_LABEL" ]; then + echo "Issue #$NUMBER: Already has label '$TARGET_LABEL'." + else + echo "Issue #$NUMBER: Adding label '$TARGET_LABEL'." + # Create label if it doesn't exist (ignores error if exists) + gh label create "$TARGET_LABEL" --repo "$OWNER/$REPO" --color "$LABEL_COLOR" || true + + gh issue edit "$NUMBER" --repo "$OWNER/$REPO" --add-label "$TARGET_LABEL" + fi + done diff --git a/README.MD b/README.MD index 0852c987..745337ba 100644 --- a/README.MD +++ b/README.MD @@ -10,19 +10,22 @@ The repository includes: or to the project defined by `project-url` input parameter with status `new`. When a new PR is added, the PR is assigned to its creator. When a PR is set to ready, reviewers from `reviewers-team` input parameter (default value - `backend-devs`) or `reviewers-individuals` (comma separated) are added. The - workflow also includes automated stale issue management: - - Identifies issues older than `stale-days` (default: 60) without a current or - future iteration and marks them as stale - - Automatically removes the stale label if an issue is added to an iteration - - Closes stale issues as "not planned" after `close-after-days` (default: 7) - without updates or being added to an iteration - - Supports `skip-label` (default: "keep") to exclude specific issues from - stale processing - issues with this label are never marked stale or closed - - Supports `dry-run` mode (default: false) for testing changes without - applying them - - Runs daily at midnight via schedule, can be triggered manually via - workflow_dispatch with custom parameters + `backend-devs`) or `reviewers-individuals` (comma separated) are added. +- [`manage-issues`](.github/workflows/manage-issues.yaml) workflow: automated + issue and PR management including stale issue detection and priority + synchronization. The workflow includes two main jobs: + - **Stale Issue Management**: Identifies issues older than `stale-days` + (default: 60) without a current or future iteration and marks them as stale. + Automatically removes the stale label if an issue is added to an iteration. + Closes stale issues as "not planned" after `close-after-days` (default: 7) + without updates or being added to an iteration. Supports `skip-label` + (default: "keep") to exclude specific issues from stale processing - issues + with this label are never marked stale or closed. Supports `dry-run` mode + (default: false) for testing changes without applying them. + - **Priority Synchronization**: Syncs priority values from GitHub Projects to + issue labels (P0-P3) for security-labeled issues. Runs daily at midnight via + schedule, can be triggered manually via workflow_dispatch with custom + parameters, or called by other workflows. - [`check-pr`](.github/workflows/check-pr.yaml) workflow: when a new PR is added to a repository or any change occurs to the PR, the PR is validated to be sure that labels are valid.