diff --git a/.github/workflows/create_test_issue.yml b/.github/workflows/create_test_issue.yml new file mode 100644 index 0000000..294502d --- /dev/null +++ b/.github/workflows/create_test_issue.yml @@ -0,0 +1,42 @@ +name: Create Test Issue on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + create_test_issue: + runs-on: ubuntu-latest + steps: + - name: Create test issue + id: create_issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PAT_TOKEN }} + script: | + const issue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Test issue for PR #' + context.issue.number, + body: 'This is a test issue created to verify the issue mover script.', + labels: ['Size: Small'] + }); + console.log('Test issue created:', issue.data.html_url); + core.setOutput('issue_number', issue.data.number); + + - name: Wait for move action + run: sleep 30 + + - name: Clean up test issue + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PAT_TOKEN }} + script: | + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create_issue.outputs.issue_number }}, + state: 'closed' + }); + console.log('Test issue closed'); diff --git a/.github/workflows/move_issue.yml b/.github/workflows/move_issue.yml index ecd004d..d145ad9 100644 --- a/.github/workflows/move_issue.yml +++ b/.github/workflows/move_issue.yml @@ -1,22 +1,23 @@ -name: Test Move Issue Action +name: Move Labeled Issues on: - workflow_dispatch: - inputs: - branch: - description: "Name of branch to test" - required: true - type: string + issues: + types: [labeled] + +env: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + PROJECT_URL: ${{ secrets.PROJECT_URL }} jobs: - test-move-issue: + move_issues: runs-on: ubuntu-latest steps: - name: Move Issue to Project Column - uses: m7kvqbe1/github-action-move-issues@${{ github.event.inputs.branch }} + uses: m7kvqbe1/github-action-move-issues@main with: github-token: ${{ secrets.PAT_TOKEN }} - project-url: ${{ secrets.PROJECT_URL }} + project-url: ${{ env.PROJECT_URL }} target-labels: "Size: Small, Size: Medium" target-column: "Todo" ignored-columns: "In Progress, Done" + default-column: "Candidates" diff --git a/README.md b/README.md index ff5bdfd..10dbc6b 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ # Move Issue to Project Column -A GitHub Action to move issues between GitHub Projects V2 columns based on specific labels and criteria. +A GitHub Action to move issues between GitHub Projects V2 columns based on specific labels and criteria. It can handle both labeling and unlabeling events. ## Inputs -| Input | Description | Required | -| ----------------- | -------------------------------------------------------------------------------------------------- | -------- | -| `github-token` | Create a Personal Access Token (Classic) with the `public_repo` and `project` scopes. | Yes | -| `project-url` | The URL of the GitHub Project V2. | Yes | -| `target-labels` | Comma-separated list of labels that should trigger the action (e.g., "Size: Small, Size: Medium"). | Yes | -| `target-column` | The target column name to move the issue to (e.g., "Candidates for Ready"). | Yes | -| `ignored-columns` | Comma-separated list of column names to ignore (e.g., "Ready, In Progress, In Review, Done"). | Yes | +| Input | Description | Required | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | -------- | +| `github-token` | Create a Personal Access Token (Classic) with the `public_repo` and `project` scopes. | Yes | +| `project-url` | The URL of the GitHub Project V2. | Yes | +| `target-labels` | Comma-separated list of labels that should trigger the action (e.g., "Size: Small, Size: Medium"). | Yes | +| `target-column` | The target column name to move the issue to when labeled (e.g., "Candidates for Ready"). | Yes | +| `ignored-columns` | Comma-separated list of column names to ignore (e.g., "Ready, In Progress, In Review, Done"). | Yes | +| `default-column` | The column to move the issue to when a target label is removed. If not specified, no action will be taken on unlabeling. | No | ## Example Workflow ```yaml -name: Move Issue on Label +name: Move Issue on Label Change on: issues: - types: [labeled] + types: [labeled, unlabeled] jobs: move-issue: @@ -33,6 +34,15 @@ jobs: target-labels: "Size: Small, Size: Medium" target-column: "Candidates for Ready" ignored-columns: "Ready, In Progress, In Review, Done" + default-column: "To Do" # Optional: Remove this line if you don't want issues moved when labels are removed ``` Get the latest `{release}` tag from https://github.com/m7kvqbe1/github-action-move-issues/releases. + +## Behavior + +- When an issue is labeled with one of the target labels, it will be moved to the specified target column. +- When all target labels are removed from an issue: + - If a default column is specified, the issue will be moved to that column. + - If no default column is specified, no action will be taken. +- The action will not move issues that are already in one of the ignored columns. diff --git a/action.yml b/action.yml index b406b2d..3a2f104 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ -name: "Move Issue to Project Column" -description: "A GitHub Action to move issues between GitHub Projects V2 columns based on specific labels and criteria." +name: "Move Issue in Project" +description: "A GitHub Action to move issues between GitHub Projects V2 columns based on labeling and unlabeling events." author: "m7kvqbe1" inputs: @@ -13,11 +13,14 @@ inputs: description: 'Comma-separated list of labels that should trigger the action (e.g., "Size: Small, Size: Medium")' required: true target-column: - description: 'The target column name to move the issue to (e.g., "Candidates for Ready")' + description: 'The target column name to move the issue to when labeled (e.g., "Candidates for Ready")' required: true ignored-columns: description: 'Comma-separated list of column names to ignore (e.g., "Ready, In Progress, In Review, Done")' required: true + default-column: + description: 'The column to move the issue to when a target label is removed. If not specified, no action will be taken on unlabeling.' + required: false runs: using: "node20" diff --git a/index.js b/index.js index 77175bc..cc97ed1 100644 --- a/index.js +++ b/index.js @@ -258,6 +258,102 @@ const processIssueItem = async ( console.log(`Moved issue #${issue.number} to "${TARGET_COLUMN}"`); }; +const handleLabeledEvent = async ( + octokit, + issue, + projectData, + TARGET_COLUMN, + IGNORED_COLUMNS, + TARGET_LABELS +) => { + validateIssue(issue, TARGET_LABELS); + + await processIssueItem( + octokit, + projectData, + issue, + TARGET_COLUMN, + IGNORED_COLUMNS + ); +}; + +const handleUnlabeledEvent = async ( + octokit, + issue, + projectData, + DEFAULT_COLUMN, + IGNORED_COLUMNS, + TARGET_LABELS +) => { + const removedLabel = github.context.payload.label.name; + if (!TARGET_LABELS.includes(removedLabel)) { + return; + } + + const hasTargetLabel = issue.labels.some((label) => + TARGET_LABELS.includes(label.name) + ); + + if (hasTargetLabel) { + console.log( + `Issue #${issue.number} still has a target label. Not moving to default column.` + ); + return; + } + + await moveIssueToDefaultColumn( + octokit, + projectData, + issue, + DEFAULT_COLUMN, + IGNORED_COLUMNS + ); +}; + +const moveIssueToDefaultColumn = async ( + octokit, + projectData, + issue, + defaultColumn, + ignoredColumns +) => { + const statusField = await getStatusField(octokit, projectData.id); + const defaultStatusOption = getTargetStatusOption(statusField, defaultColumn); + + if (!defaultStatusOption) { + throw new Error(`Default column "${defaultColumn}" not found in project`); + } + + let issueItemData = await getIssueItemData( + octokit, + projectData.id, + issue.node_id + ); + + if (!issueItemData) { + console.log(`Issue #${issue.number} is not in the project. Skipping.`); + return; + } + + const currentStatus = getCurrentStatus(issueItemData); + + if (ignoredColumns.includes(currentStatus)) { + console.log( + `Issue #${issue.number} is in an ignored column (${currentStatus}). Skipping.` + ); + return; + } + + await updateIssueStatus( + octokit, + projectData.id, + issueItemData.id, + statusField.id, + defaultStatusOption.id + ); + console.log(`Moved issue #${issue.number} back to "${defaultColumn}"`); +}; + const run = async () => { try { const token = core.getInput("github-token"); @@ -265,27 +361,46 @@ const run = async () => { const targetLabels = core.getInput("target-labels"); const targetColumn = core.getInput("target-column"); const ignoredColumns = core.getInput("ignored-columns"); + const defaultColumn = core.getInput("default-column", { required: false }); const TARGET_COLUMN = targetColumn.trim(); const TARGET_LABELS = parseCommaSeparatedInput(targetLabels); const IGNORED_COLUMNS = parseCommaSeparatedInput(ignoredColumns); + const DEFAULT_COLUMN = defaultColumn ? defaultColumn.trim() : null; const octokit = github.getOctokit(token); const issue = github.context.payload.issue; - - validateIssue(issue, TARGET_LABELS); + const action = github.context.payload.action; const projectData = await getProjectData(octokit, projectUrl); - await processIssueItem( - octokit, - projectData, - issue, - TARGET_COLUMN, - IGNORED_COLUMNS - ); + if (action === "labeled") { + await handleLabeledEvent( + octokit, + issue, + projectData, + TARGET_COLUMN, + IGNORED_COLUMNS, + TARGET_LABELS + ); + return; + } + + if (action === "unlabeled" && DEFAULT_COLUMN) { + await handleUnlabeledEvent( + octokit, + issue, + projectData, + DEFAULT_COLUMN, + IGNORED_COLUMNS, + TARGET_LABELS + ); + return; + } + + console.log(`No action taken for ${action} event.`); } catch (error) { - core.setFailed(`Error moving issue: ${error.message}`); + core.setFailed(`Error processing issue: ${error.message}`); } };