├── .gitattributes ├── .github ├── copilot-instructions.md └── workflows │ ├── ci-build.yml │ ├── labeler-cache-retention.yml │ ├── labeler-predict-issues.yml │ ├── labeler-predict-pulls.yml │ ├── labeler-promote.yml │ ├── labeler-train.yml │ └── release.yml ├── .gitignore ├── CODE-OF-CONDUCT.md ├── IssueLabeler ├── Directory.Build.props ├── Directory.Packages.props ├── IssueLabeler.sln ├── NuGet.config ├── src │ ├── Common │ │ ├── App.cs │ │ ├── ArgUtils.cs │ │ ├── Common.csproj │ │ ├── DataFileUtils.cs │ │ ├── GitHubActionSummary.cs │ │ └── ModelType.cs │ ├── Downloader │ │ ├── Args.cs │ │ ├── Downloader.cs │ │ └── Downloader.csproj │ ├── GitHubClient │ │ ├── GitHubApi.cs │ │ ├── GitHubClient.csproj │ │ └── QueryModel.cs │ ├── Predictor │ │ ├── Args.cs │ │ ├── Models.cs │ │ ├── Predictor.cs │ │ └── Predictor.csproj │ ├── Tester │ │ ├── Args.cs │ │ ├── Models.cs │ │ ├── Tester.cs │ │ └── Tester.csproj │ └── Trainer │ │ ├── Args.cs │ │ ├── Trainer.cs │ │ └── Trainer.csproj └── tests │ └── Common.Tests │ ├── ArgUtils.Tests.cs │ ├── Common.Tests.csproj │ └── DataFileUtilsTests.cs ├── LICENSE.TXT ├── README.md ├── THIRD-PARTY-NOTICES ├── download └── action.yml ├── predict └── action.yml ├── promote └── action.yml ├── restore └── action.yml ├── test └── action.yml ├── testEnvironments.json └── train └── action.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto encoding=UTF-8 5 | 6 | # csc/vbc are shell scripts and should always have unix line endings 7 | # These shell scripts are included in the toolset packages. Normally, the shell 8 | # scripts in our repo are only run by cloning onto a Linux/Mac machine, and git 9 | # automatically chooses LF as the line ending. 10 | # 11 | # However, right now the toolset packages must be built on Windows, and so the 12 | # files must be hard-coded to be cloned with LF 13 | src/Compilers/CSharp/CscCore/csc text eol=lf 14 | src/Compilers/VisualBasic/VbcCore/vbc text eol=lf 15 | 16 | ############################################################################### 17 | # Set default behavior for command prompt diff. 18 | # 19 | # This is need for earlier builds of msysgit that does not have it on by 20 | # default for csharp files. 21 | # Note: This is only used by command line 22 | ############################################################################### 23 | *.cs diff=csharp text 24 | *.vb text 25 | 26 | ############################################################################### 27 | # Set the merge driver for project and solution files 28 | # 29 | # Merging from the command prompt will add diff markers to the files if there 30 | # are conflicts (Merging from VS is not affected by the settings below, in VS 31 | # the diff markers are never inserted). Diff markers may cause the following 32 | # file extensions to fail to load in VS. An alternative would be to treat 33 | # these files as binary and thus will always conflict and require user 34 | # intervention with every merge. To do so, just uncomment the entries below 35 | ############################################################################### 36 | #*.sln merge=binary 37 | #*.csproj merge=binary 38 | #*.vbproj merge=binary 39 | #*.vcxproj merge=binary 40 | #*.vcproj merge=binary 41 | #*.dbproj merge=binary 42 | #*.fsproj merge=binary 43 | #*.lsproj merge=binary 44 | #*.wixproj merge=binary 45 | #*.modelproj merge=binary 46 | #*.sqlproj merge=binary 47 | #*.wwaproj merge=binary 48 | 49 | ############################################################################### 50 | # behavior for image files 51 | # 52 | # image files are treated as binary by default. 53 | ############################################################################### 54 | #*.jpg binary 55 | #*.png binary 56 | #*.gif binary 57 | 58 | ############################################################################### 59 | # diff behavior for common document formats 60 | # 61 | # Convert binary document formats to text before diffing them. This feature 62 | # is only available from the command line. Turn it on by uncommenting the 63 | # entries below. 64 | ############################################################################### 65 | #*.doc diff=astextplain 66 | #*.DOC diff=astextplain 67 | #*.docx diff=astextplain 68 | #*.DOCX diff=astextplain 69 | #*.dot diff=astextplain 70 | #*.DOT diff=astextplain 71 | #*.pdf diff=astextplain 72 | #*.PDF diff=astextplain 73 | #*.rtf diff=astextplain 74 | #*.RTF diff=astextplain 75 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Instructions for GitHub and VisualStudio Copilot 2 | ### https://github.blog/changelog/2025-01-21-custom-repository-instructions-are-now-available-for-copilot-on-github-com-public-preview/ 3 | 4 | 5 | ## General 6 | 7 | * Make only high confidence suggestions when reviewing code changes. 8 | * Always use the latest version C#, currently C# 13 features. 9 | * Files must have CRLF line endings. 10 | 11 | ## Formatting 12 | 13 | * Apply code-formatting style defined in `.editorconfig`. 14 | * Prefer file-scoped namespace declarations and single-line using directives. 15 | * Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). 16 | * Ensure that the final return statement of a method is on its own line. 17 | * Use pattern matching and switch expressions wherever possible. 18 | * Use `nameof` instead of string literals when referring to member names. 19 | 20 | ### **Variable Declarations:** 21 | 22 | * Never use `var` for primitive types. Use `var` only when the type is obvious from context. When in doubt, opt for an explicit type declaration. 23 | 24 | ### Nullable Reference Types 25 | 26 | * Declare variables non-nullable, and check for `null` at entry points. 27 | * Always use `is null` or `is not null` instead of `== null` or `!= null`. 28 | * Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. 29 | 30 | 31 | ### Testing 32 | 33 | * We use MSTest SDK 34 | * Do not emit "Act", "Arrange" or "Assert" comments. 35 | * Use NSubstitute for mocking. 36 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yml: -------------------------------------------------------------------------------- 1 | # CI Build and Test of the IssueLabeler solution 2 | name: "CI Build" 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - ".github/workflows/ci-*.yml" 10 | - "IssueLabeler/**" 11 | 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | workflow_dispatch: 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: "Set up the .NET SDK" 26 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 27 | with: 28 | dotnet-version: 9.0.x 29 | 30 | - name: "Build the IssueLabeler solution" 31 | run: dotnet build IssueLabeler/ --configuration Release 32 | 33 | test: 34 | needs: build 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | 40 | - name: "Set up the .NET SDK" 41 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 42 | with: 43 | dotnet-version: 9.0.x 44 | 45 | - name: "Run tests from the IssueLabeler solution" 46 | run: dotnet test IssueLabeler/ 47 | -------------------------------------------------------------------------------- /.github/workflows/labeler-cache-retention.yml: -------------------------------------------------------------------------------- 1 | # Regularly restore the prediction models from cache to prevent cache eviction 2 | name: "Labeler: Cache Retention" 3 | 4 | # For more information about GitHub's action cache limits and eviction policy, see: 5 | # https://docs.github.com/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy 6 | 7 | on: 8 | schedule: 9 | - cron: "36 4 * * *" # 4:36 every day (arbitrary time daily) 10 | 11 | workflow_dispatch: 12 | inputs: 13 | cache_key: 14 | description: "The cache key suffix to use for restoring the model from cache. Defaults to 'ACTIVE'." 15 | required: true 16 | default: "ACTIVE" 17 | 18 | env: 19 | CACHE_KEY: ${{ inputs.cache_key || 'ACTIVE' }} 20 | 21 | jobs: 22 | restore-cache: 23 | # Do not automatically run the workflow on forks outside the 'dotnet' org 24 | if: ${{ github.event_name == 'workflow_dispatch' || github.repository_owner == 'dotnet' }} 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | type: ["issues", "pulls"] 30 | steps: 31 | - uses: dotnet/issue-labeler/restore@main 32 | with: 33 | type: ${{ matrix.type }} 34 | cache_key: ${{ env.CACHE_KEY }} 35 | fail-on-cache-miss: true 36 | -------------------------------------------------------------------------------- /.github/workflows/labeler-predict-issues.yml: -------------------------------------------------------------------------------- 1 | # Predict labels for Issues using a trained model 2 | name: "Labeler: Predict (Issues)" 3 | 4 | on: 5 | # Only automatically predict area labels when issues are first opened 6 | issues: 7 | types: opened 8 | 9 | # Allow dispatching the workflow via the Actions UI, specifying ranges of numbers 10 | workflow_dispatch: 11 | inputs: 12 | issues: 13 | description: "Issue Numbers (comma-separated list of ranges)." 14 | required: true 15 | cache_key: 16 | description: "The cache key suffix to use for restoring the model. Defaults to 'ACTIVE'." 17 | required: true 18 | default: "ACTIVE" 19 | 20 | env: 21 | # Do not allow failure for jobs triggered automatically (as this causes red noise on the workflows list) 22 | ALLOW_FAILURE: ${{ github.event_name == 'workflow_dispatch' }} 23 | 24 | LABEL_PREFIX: "area-" 25 | THRESHOLD: 0.40 26 | DEFAULT_LABEL: "needs-area-label" 27 | 28 | jobs: 29 | predict-issue-label: 30 | # Do not automatically run the workflow on forks outside the 'dotnet' org 31 | if: ${{ github.event_name == 'workflow_dispatch' || github.repository_owner == 'dotnet' }} 32 | runs-on: ubuntu-latest 33 | permissions: 34 | issues: write 35 | steps: 36 | - name: "Restore issues model from cache" 37 | id: restore-model 38 | uses: dotnet/issue-labeler/restore@main 39 | with: 40 | type: issues 41 | fail-on-cache-miss: ${{ env.ALLOW_FAILURE }} 42 | quiet: true 43 | 44 | - name: "Predict issue labels" 45 | id: prediction 46 | if: ${{ steps.restore-model.outputs.cache-hit == 'true' }} 47 | uses: dotnet/issue-labeler/predict@main 48 | with: 49 | issues: ${{ inputs.issues || github.event.issue.number }} 50 | label_prefix: ${{ env.LABEL_PREFIX }} 51 | threshold: ${{ env.THRESHOLD }} 52 | default_label: ${{ env.DEFAULT_LABEL }} 53 | env: 54 | GITHUB_TOKEN: ${{ github.token }} 55 | continue-on-error: ${{ !env.ALLOW_FAILURE }} 56 | -------------------------------------------------------------------------------- /.github/workflows/labeler-predict-pulls.yml: -------------------------------------------------------------------------------- 1 | # Predict labels for Pull Requests using a trained model 2 | name: "Labeler: Predict (Pulls)" 3 | 4 | on: 5 | # Per to the following documentation: 6 | # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request_target 7 | # 8 | # The `pull_request_target` event runs in the context of the base of the pull request, rather 9 | # than in the context of the merge commit, as the `pull_request` event does. This prevents 10 | # execution of unsafe code from the head of the pull request that could alter the repository 11 | # or steal any secrets you use in your workflow. This event allows your workflow to do things 12 | # like label or comment on pull requests from forks. 13 | # 14 | # Only automatically predict area labels when pull requests are first opened 15 | pull_request_target: 16 | types: opened 17 | 18 | # Allow dispatching the workflow via the Actions UI, specifying ranges of numbers 19 | workflow_dispatch: 20 | inputs: 21 | pulls: 22 | description: "Pull Request Numbers (comma-separated list of ranges)." 23 | required: true 24 | cache_key: 25 | description: "The cache key suffix to use for restoring the model. Defaults to 'ACTIVE'." 26 | required: true 27 | default: "ACTIVE" 28 | 29 | env: 30 | # Do not allow failure for jobs triggered automatically (this can block PR merge) 31 | ALLOW_FAILURE: ${{ github.event_name == 'workflow_dispatch' }} 32 | 33 | LABEL_PREFIX: "area-" 34 | THRESHOLD: 0.40 35 | DEFAULT_LABEL: "needs-area-label" 36 | 37 | jobs: 38 | predict-pull-label: 39 | # Do not automatically run the workflow on forks outside the 'dotnet' org 40 | if: ${{ github.event_name == 'workflow_dispatch' || github.repository_owner == 'dotnet' }} 41 | runs-on: ubuntu-latest 42 | permissions: 43 | pull-requests: write 44 | steps: 45 | - name: "Restore pulls model from cache" 46 | id: restore-model 47 | uses: dotnet/issue-labeler/restore@main 48 | with: 49 | type: pulls 50 | fail-on-cache-miss: ${{ env.ALLOW_FAILURE }} 51 | quiet: true 52 | 53 | - name: "Predict pull labels" 54 | id: prediction 55 | if: ${{ steps.restore-model.outputs.cache-hit == 'true' }} 56 | uses: dotnet/issue-labeler/predict@main 57 | with: 58 | pulls: ${{ inputs.pulls || github.event.number }} 59 | label_prefix: ${{ env.LABEL_PREFIX }} 60 | threshold: ${{ env.THRESHOLD }} 61 | default_label: ${{ env.DEFAULT_LABEL }} 62 | env: 63 | GITHUB_TOKEN: ${{ github.token }} 64 | continue-on-error: ${{ !env.ALLOW_FAILURE }} 65 | -------------------------------------------------------------------------------- /.github/workflows/labeler-promote.yml: -------------------------------------------------------------------------------- 1 | # Promote a model from staging to 'ACTIVE', backing up the currently 'ACTIVE' model 2 | name: "Labeler: Promotion" 3 | 4 | on: 5 | # Dispatched via the Actions UI, promotes the staged models from 6 | # a staged slot into the prediction environment 7 | workflow_dispatch: 8 | inputs: 9 | issues: 10 | description: "Issues: Promote Model" 11 | type: boolean 12 | required: true 13 | pulls: 14 | description: "Pulls: Promote Model" 15 | type: boolean 16 | required: true 17 | staged_key: 18 | description: "The cache key suffix to use for promoting a staged model to 'ACTIVE'. Defaults to 'staged'." 19 | required: true 20 | default: "staged" 21 | backup_key: 22 | description: "The cache key suffix to use for backing up the currently active model. Defaults to 'backup'." 23 | default: "backup" 24 | 25 | permissions: 26 | actions: write 27 | 28 | jobs: 29 | promote-issues: 30 | if: ${{ inputs.issues }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: "Promote Model for Issues" 34 | uses: dotnet/issue-labeler/promote@main 35 | with: 36 | type: "issues" 37 | staged_key: ${{ inputs.staged_key }} 38 | backup_key: ${{ inputs.backup_key }} 39 | 40 | promote-pulls: 41 | if: ${{ inputs.pulls }} 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: "Promote Model for Pull Requests" 45 | uses: dotnet/issue-labeler/promote@main 46 | with: 47 | type: "pulls" 48 | staged_key: ${{ inputs.staged_key }} 49 | backup_key: ${{ inputs.backup_key }} 50 | -------------------------------------------------------------------------------- /.github/workflows/labeler-train.yml: -------------------------------------------------------------------------------- 1 | # Train the Issues and Pull Requests models for label prediction 2 | name: "Labeler: Training" 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | type: 8 | description: "Issues or Pull Requests" 9 | type: choice 10 | required: true 11 | default: "Both" 12 | options: 13 | - "Both" 14 | - "Issues" 15 | - "Pull Requests" 16 | 17 | steps: 18 | description: "Training Steps" 19 | type: choice 20 | required: true 21 | default: "All" 22 | options: 23 | - "All" 24 | - "Download Data" 25 | - "Train Model" 26 | - "Test Model" 27 | 28 | repository: 29 | description: "The org/repo to download data from. Defaults to the current repository." 30 | limit: 31 | description: "Max number of items to download for training/testing the model (newest items are used). Defaults to the max number of pages times the page size." 32 | type: number 33 | page_size: 34 | description: "Number of items per page in GitHub API requests. Defaults to 100 for issues, 25 for pull requests." 35 | type: number 36 | page_limit: 37 | description: "Maximum number of pages to download for training/testing the model. Defaults to 1000 for issues, 4000 for pull requests." 38 | type: number 39 | cache_key_suffix: 40 | description: "The cache key suffix to use for staged data/models (use 'ACTIVE' to bypass staging). Defaults to 'staged'." 41 | required: true 42 | default: "staged" 43 | 44 | env: 45 | CACHE_KEY: ${{ inputs.cache_key_suffix }} 46 | REPOSITORY: ${{ inputs.repository || github.repository }} 47 | LABEL_PREFIX: "area-" 48 | THRESHOLD: "0.40" 49 | LIMIT: ${{ inputs.limit }} 50 | PAGE_SIZE: ${{ inputs.page_size }} 51 | PAGE_LIMIT: ${{ inputs.page_limit }} 52 | 53 | jobs: 54 | download-issues: 55 | if: ${{ contains(fromJSON('["Both", "Issues"]'), inputs.type) && contains(fromJSON('["All", "Download Data"]'), inputs.steps) }} 56 | runs-on: ubuntu-latest 57 | permissions: 58 | issues: read 59 | steps: 60 | - name: "Download Issues" 61 | uses: dotnet/issue-labeler/download@main 62 | with: 63 | type: "issues" 64 | cache_key: ${{ env.CACHE_KEY }} 65 | repository: ${{ env.REPOSITORY }} 66 | label_prefix: ${{ env.LABEL_PREFIX }} 67 | limit: ${{ env.LIMIT }} 68 | page_size: ${{ env.PAGE_SIZE }} 69 | page_limit: ${{ env.PAGE_LIMIT }} 70 | env: 71 | GITHUB_TOKEN: ${{ github.token }} 72 | 73 | download-pulls: 74 | if: ${{ contains(fromJSON('["Both", "Pull Requests"]'), inputs.type) && contains(fromJSON('["All", "Download Data"]'), inputs.steps) }} 75 | runs-on: ubuntu-latest 76 | permissions: 77 | pull-requests: read 78 | steps: 79 | - name: "Download Pull Requests" 80 | uses: dotnet/issue-labeler/download@main 81 | with: 82 | type: "pulls" 83 | cache_key: ${{ env.CACHE_KEY }} 84 | repository: ${{ env.REPOSITORY }} 85 | label_prefix: ${{ env.LABEL_PREFIX }} 86 | limit: ${{ env.LIMIT }} 87 | page_size: ${{ env.PAGE_SIZE }} 88 | page_limit: ${{ env.PAGE_LIMIT }} 89 | env: 90 | GITHUB_TOKEN: ${{ github.token }} 91 | 92 | train-issues: 93 | if: ${{ always() && contains(fromJSON('["Both", "Issues"]'), inputs.type) && contains(fromJSON('["All", "Train Model"]'), inputs.steps) && contains(fromJSON('["success", "skipped"]'), needs.download-issues.result) }} 94 | runs-on: ubuntu-latest 95 | permissions: {} 96 | needs: download-issues 97 | steps: 98 | - name: "Train Model for Issues" 99 | uses: dotnet/issue-labeler/train@main 100 | with: 101 | type: "issues" 102 | data_cache_key: ${{ env.CACHE_KEY }} 103 | model_cache_key: ${{ env.CACHE_KEY }} 104 | 105 | train-pulls: 106 | if: ${{ always() && contains(fromJSON('["Both", "Pull Requests"]'), inputs.type) && contains(fromJSON('["All", "Train Model"]'), inputs.steps) && contains(fromJSON('["success", "skipped"]'), needs.download-pulls.result) }} 107 | runs-on: ubuntu-latest 108 | permissions: {} 109 | needs: download-pulls 110 | steps: 111 | - name: "Train Model for Pull Requests" 112 | uses: dotnet/issue-labeler/train@main 113 | with: 114 | type: "pulls" 115 | data_cache_key: ${{ env.CACHE_KEY }} 116 | model_cache_key: ${{ env.CACHE_KEY }} 117 | 118 | test-issues: 119 | if: ${{ always() && contains(fromJSON('["Both", "Issues"]'), inputs.type) && contains(fromJSON('["All", "Test Model"]'), inputs.steps) && contains(fromJSON('["success", "skipped"]'), needs.train-issues.result) }} 120 | runs-on: ubuntu-latest 121 | permissions: 122 | issues: read 123 | needs: train-issues 124 | steps: 125 | - name: "Test Model for Issues" 126 | uses: dotnet/issue-labeler/test@main 127 | with: 128 | type: "issues" 129 | cache_key: ${{ env.CACHE_KEY }} 130 | repository: ${{ env.REPOSITORY }} 131 | label_prefix: ${{ env.LABEL_PREFIX }} 132 | threshold: ${{ env.THRESHOLD }} 133 | limit: ${{ env.LIMIT }} 134 | page_size: ${{ env.PAGE_SIZE }} 135 | page_limit: ${{ env.PAGE_LIMIT }} 136 | env: 137 | GITHUB_TOKEN: ${{ github.token }} 138 | 139 | test-pulls: 140 | if: ${{ always() && contains(fromJSON('["Both", "Pull Requests"]'), inputs.type) && contains(fromJSON('["All", "Test Model"]'), inputs.steps) && contains(fromJSON('["success", "skipped"]'), needs.train-pulls.result) }} 141 | runs-on: ubuntu-latest 142 | permissions: 143 | pull-requests: read 144 | needs: train-pulls 145 | steps: 146 | - name: "Test Model for Pull Requests" 147 | uses: dotnet/issue-labeler/test@main 148 | with: 149 | type: "pulls" 150 | cache_key: ${{ env.CACHE_KEY }} 151 | repository: ${{ env.REPOSITORY }} 152 | label_prefix: ${{ env.LABEL_PREFIX }} 153 | threshold: ${{ env.THRESHOLD }} 154 | limit: ${{ env.LIMIT }} 155 | page_size: ${{ env.PAGE_SIZE }} 156 | page_limit: ${{ env.PAGE_LIMIT }} 157 | env: 158 | GITHUB_TOKEN: ${{ github.token }} 159 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Create a new release of the Issue Labeler, publishing the predictor Docker container image to the GitHub container registry 2 | name: "Release" 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | image_tags: 8 | description: "The optional semicolon separated list of tags to apply to the published Docker container image. The ref name is added automatically." 9 | 10 | env: 11 | BASE_IMAGE: mcr.microsoft.com/dotnet/runtime:9.0-noble-chiseled 12 | IMAGE_TAGS: ${{ inputs.image_tags && format('{0};{1}', github.ref_name, inputs.image_tags) || github.ref_name }} 13 | PREDICTOR_IMAGE_NAME: ${{ github.repository }}/predictor 14 | PACKAGE_NAME_ESCAPED: issue-labeler%2Fpredictor 15 | GITHUB_API_PACKAGE_OWNER: /orgs/dotnet 16 | 17 | jobs: 18 | publish-predictor: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | packages: write 23 | outputs: 24 | digest: ${{ steps.published-image.outputs.digest }} 25 | published_image_digest: ${{ steps.published-image.outputs.published_image_digest }} 26 | 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | 30 | - name: "Set up the .NET SDK" 31 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 32 | with: 33 | dotnet-version: 9.0.x 34 | 35 | - name: "Log in to the GitHub Container Registry" 36 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: "Publish Predictor" 43 | run: | 44 | dotnet publish IssueLabeler/src/Predictor/Predictor.csproj \ 45 | /t:PublishContainer \ 46 | -p DebugType=none \ 47 | -p ContainerBaseImage=${{ env.BASE_IMAGE }} \ 48 | -p ContainerRegistry=ghcr.io \ 49 | -p ContainerImageTags='"${{ env.IMAGE_TAGS }}"' \ 50 | -p ContainerRepository=${{ env.PREDICTOR_IMAGE_NAME }} \ 51 | -p ContainerAuthors=${{ github.repository_owner }} \ 52 | -p ContainerInformationUrl=${{ format('{0}/{1}', github.server_url, github.repository) }} \ 53 | -p ContainerDocumentationUrl=${{ format('{0}/{1}/wiki', github.server_url, github.repository) }} \ 54 | -p ContainerLicenseExpression=${{ format('{0}/{1}/blob/main/LICENSE.TXT', github.server_url, github.repository) }} 55 | 56 | - name: "Capture and output the Docker image digest to the workflow summary" 57 | id: published-image 58 | env: 59 | GH_TOKEN: ${{ github.token }} 60 | run: | 61 | DIGEST=` \ 62 | gh api \ 63 | -H "Accept: application/vnd.github+json" \ 64 | -H "X-GitHub-Api-Version: 2022-11-28" \ 65 | ${{ format('{0}/packages/container/{1}/versions', env.GITHUB_API_PACKAGE_OWNER, env.PACKAGE_NAME_ESCAPED) }} \ 66 | | jq -r '.[] | select(.metadata.container.tags[] == "${ github.ref_name }") | .name' \ 67 | ` 68 | PUBLISHED_IMAGE_DIGEST=ghcr.io/${{ env.PREDICTOR_IMAGE_NAME }}@${DIGEST} 69 | 70 | echo "digest=$DIGEST" >> $GITHUB_OUTPUT 71 | echo "published_image_digest=$PUBLISHED_IMAGE_DIGEST" >> $GITHUB_OUTPUT 72 | 73 | echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY 74 | echo "> **Docker container image published.**" >> $GITHUB_STEP_SUMMARY 75 | echo "> Digest: \`$DIGEST\`" >> $GITHUB_STEP_SUMMARY 76 | echo "> Published: \`$PUBLISHED_IMAGE_DIGEST\`" >> $GITHUB_STEP_SUMMARY 77 | echo "" >> $GITHUB_STEP_SUMMARY 78 | 79 | update-predictor-action: 80 | runs-on: ubuntu-latest 81 | needs: publish-predictor 82 | permissions: 83 | contents: write 84 | packages: read 85 | 86 | steps: 87 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 88 | 89 | - name: "Update the `predict` action to use the published image digest" 90 | run: | 91 | PREDICT_ACTION="predict/action.yml" 92 | sed -i "s|ghcr.io/${{ env.PREDICTOR_IMAGE_NAME }}@.*|${{ needs.publish-predictor.outputs.published_image_digest }} # ${{ env.IMAGE_TAGS }}|" $PREDICT_ACTION 93 | 94 | git config user.name "GitHub Actions" 95 | git config user.email "actions@github.com" 96 | git add $PREDICT_ACTION 97 | git commit -m "Release '${{ github.ref_name }}' with predictor digest '${{ needs.publish-predictor.outputs.digest }}'" 98 | git push origin ${{ github.ref_name }} 99 | 100 | echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY 101 | echo "> Updated [\`predict/action.yml\` (${{ github.ref_name }})](${{ format('{0}/{1}/blob/{2}/predict/action.yml', github.server_url, github.repository, github.ref_name) }}) to:" >> $GITHUB_STEP_SUMMARY 102 | echo "> \`${{ needs.publish-predictor.outputs.published_image_digest }}\`" >> $GITHUB_STEP_SUMMARY 103 | echo "" >> $GITHUB_STEP_SUMMARY 104 | 105 | echo "\`\`\`yml" >> $GITHUB_STEP_SUMMARY 106 | grep -i -B1 -A10 '^\s*using:\s*docker' $PREDICT_ACTION >> $GITHUB_STEP_SUMMARY 107 | echo "\`\`\`" >> $GITHUB_STEP_SUMMARY 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Used for local development of the issue-labeler 2 | labeler-cache/ 3 | 4 | ## Ignore Visual Studio temporary files, build results, and 5 | ## files generated by popular Visual Studio add-ons. 6 | ## 7 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 8 | 9 | # User-specific files 10 | *.rsuser 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | **/Properties/launchSettings.json 16 | 17 | # User-specific files (MonoDevelop/Xamarin Studio) 18 | *.userprefs 19 | 20 | # Mono auto generated files 21 | mono_crash.* 22 | 23 | # Build results 24 | [Dd]ebug/ 25 | [Dd]ebugPublic/ 26 | [Rr]elease/ 27 | [Rr]eleases/ 28 | x64/ 29 | x86/ 30 | [Ww][Ii][Nn]32/ 31 | [Aa][Rr][Mm]/ 32 | [Aa][Rr][Mm]64/ 33 | bld/ 34 | [Bb]in/ 35 | [Oo]bj/ 36 | [Ll]og/ 37 | [Ll]ogs/ 38 | 39 | # Visual Studio 2015/2017 cache/options directory 40 | .vs/ 41 | # Uncomment if you have tasks that create the project's static files in wwwroot 42 | #wwwroot/ 43 | 44 | # Visual Studio 2017 auto generated files 45 | Generated\ Files/ 46 | 47 | # MSTest test Results 48 | [Tt]est[Rr]esult*/ 49 | [Bb]uild[Ll]og.* 50 | 51 | # NUnit 52 | *.VisualState.xml 53 | TestResult.xml 54 | nunit-*.xml 55 | 56 | # Build Results of an ATL Project 57 | [Dd]ebugPS/ 58 | [Rr]eleasePS/ 59 | dlldata.c 60 | 61 | # Benchmark Results 62 | BenchmarkDotNet.Artifacts/ 63 | 64 | # .NET Core 65 | project.lock.json 66 | project.fragment.lock.json 67 | artifacts/ 68 | 69 | # ASP.NET Scaffolding 70 | ScaffoldingReadMe.txt 71 | 72 | # StyleCop 73 | StyleCopReport.xml 74 | 75 | # Files built by Visual Studio 76 | *_i.c 77 | *_p.c 78 | *_h.h 79 | *.ilk 80 | *.meta 81 | *.obj 82 | *.iobj 83 | *.pch 84 | *.pdb 85 | *.ipdb 86 | *.pgc 87 | *.pgd 88 | *.rsp 89 | # but not Directory.Build.rsp, as it configures directory-level build defaults 90 | !Directory.Build.rsp 91 | *.sbr 92 | *.tlb 93 | *.tli 94 | *.tlh 95 | *.tmp 96 | *.tmp_proj 97 | *_wpftmp.csproj 98 | *.log 99 | *.tlog 100 | *.vspscc 101 | *.vssscc 102 | .builds 103 | *.pidb 104 | *.svclog 105 | *.scc 106 | 107 | # Chutzpah Test files 108 | _Chutzpah* 109 | 110 | # Visual C++ cache files 111 | ipch/ 112 | *.aps 113 | *.ncb 114 | *.opendb 115 | *.opensdf 116 | *.sdf 117 | *.cachefile 118 | *.VC.db 119 | *.VC.VC.opendb 120 | 121 | # Visual Studio profiler 122 | *.psess 123 | *.vsp 124 | *.vspx 125 | *.sap 126 | 127 | # Visual Studio Trace Files 128 | *.e2e 129 | 130 | # TFS 2012 Local Workspace 131 | $tf/ 132 | 133 | # Guidance Automation Toolkit 134 | *.gpState 135 | 136 | # ReSharper is a .NET coding add-in 137 | _ReSharper*/ 138 | *.[Rr]e[Ss]harper 139 | *.DotSettings.user 140 | 141 | # TeamCity is a build add-in 142 | _TeamCity* 143 | 144 | # DotCover is a Code Coverage Tool 145 | *.dotCover 146 | 147 | # AxoCover is a Code Coverage Tool 148 | .axoCover/* 149 | !.axoCover/settings.json 150 | 151 | # Coverlet is a free, cross platform Code Coverage Tool 152 | coverage*.json 153 | coverage*.xml 154 | coverage*.info 155 | 156 | # Visual Studio code coverage results 157 | *.coverage 158 | *.coveragexml 159 | 160 | # NCrunch 161 | _NCrunch_* 162 | .*crunch*.local.xml 163 | nCrunchTemp_* 164 | 165 | # MightyMoose 166 | *.mm.* 167 | AutoTest.Net/ 168 | 169 | # Web workbench (sass) 170 | .sass-cache/ 171 | 172 | # Installshield output folder 173 | [Ee]xpress/ 174 | 175 | # DocProject is a documentation generator add-in 176 | DocProject/buildhelp/ 177 | DocProject/Help/*.HxT 178 | DocProject/Help/*.HxC 179 | DocProject/Help/*.hhc 180 | DocProject/Help/*.hhk 181 | DocProject/Help/*.hhp 182 | DocProject/Help/Html2 183 | DocProject/Help/html 184 | 185 | # Click-Once directory 186 | publish/ 187 | 188 | # Publish Web Output 189 | *.[Pp]ublish.xml 190 | *.azurePubxml 191 | # Note: Comment the next line if you want to checkin your web deploy settings, 192 | # but database connection strings (with potential passwords) will be unencrypted 193 | *.pubxml 194 | *.publishproj 195 | 196 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 197 | # checkin your Azure Web App publish settings, but sensitive information contained 198 | # in these scripts will be unencrypted 199 | PublishScripts/ 200 | 201 | # NuGet Packages 202 | *.nupkg 203 | # NuGet Symbol Packages 204 | *.snupkg 205 | # The packages folder can be ignored because of Package Restore 206 | **/[Pp]ackages/* 207 | # except build/, which is used as an MSBuild target. 208 | !**/[Pp]ackages/build/ 209 | # Uncomment if necessary however generally it will be regenerated when needed 210 | #!**/[Pp]ackages/repositories.config 211 | # NuGet v3's project.json files produces more ignorable files 212 | *.nuget.props 213 | *.nuget.targets 214 | 215 | # Microsoft Azure Build Output 216 | csx/ 217 | *.build.csdef 218 | 219 | # Microsoft Azure Emulator 220 | ecf/ 221 | rcf/ 222 | 223 | # Windows Store app package directories and files 224 | AppPackages/ 225 | BundleArtifacts/ 226 | Package.StoreAssociation.xml 227 | _pkginfo.txt 228 | *.appx 229 | *.appxbundle 230 | *.appxupload 231 | 232 | # Visual Studio cache files 233 | # files ending in .cache can be ignored 234 | *.[Cc]ache 235 | # but keep track of directories ending in .cache 236 | !?*.[Cc]ache/ 237 | 238 | # Others 239 | ClientBin/ 240 | ~$* 241 | *~ 242 | *.dbmdl 243 | *.dbproj.schemaview 244 | *.jfm 245 | *.pfx 246 | *.publishsettings 247 | orleans.codegen.cs 248 | 249 | # Including strong name files can present a security risk 250 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 251 | #*.snk 252 | 253 | # Since there are multiple workflows, uncomment next line to ignore bower_components 254 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 255 | #bower_components/ 256 | 257 | # RIA/Silverlight projects 258 | Generated_Code/ 259 | 260 | # Backup & report files from converting an old project file 261 | # to a newer Visual Studio version. Backup files are not needed, 262 | # because we have git ;-) 263 | _UpgradeReport_Files/ 264 | Backup*/ 265 | UpgradeLog*.XML 266 | UpgradeLog*.htm 267 | ServiceFabricBackup/ 268 | *.rptproj.bak 269 | 270 | # SQL Server files 271 | *.mdf 272 | *.ldf 273 | *.ndf 274 | 275 | # Business Intelligence projects 276 | *.rdl.data 277 | *.bim.layout 278 | *.bim_*.settings 279 | *.rptproj.rsuser 280 | *- [Bb]ackup.rdl 281 | *- [Bb]ackup ([0-9]).rdl 282 | *- [Bb]ackup ([0-9][0-9]).rdl 283 | 284 | # Microsoft Fakes 285 | FakesAssemblies/ 286 | 287 | # GhostDoc plugin setting file 288 | *.GhostDoc.xml 289 | 290 | # Node.js Tools for Visual Studio 291 | .ntvs_analysis.dat 292 | node_modules/ 293 | 294 | # Visual Studio 6 build log 295 | *.plg 296 | 297 | # Visual Studio 6 workspace options file 298 | *.opt 299 | 300 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 301 | *.vbw 302 | 303 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 304 | *.vbp 305 | 306 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 307 | *.dsw 308 | *.dsp 309 | 310 | # Visual Studio 6 technical files 311 | *.ncb 312 | *.aps 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # CodeRush personal settings 330 | .cr/personal 331 | 332 | # Python Tools for Visual Studio (PTVS) 333 | __pycache__/ 334 | *.pyc 335 | 336 | # Cake - Uncomment if you are using it 337 | # tools/** 338 | # !tools/packages.config 339 | 340 | # Tabs Studio 341 | *.tss 342 | 343 | # Telerik's JustMock configuration file 344 | *.jmconfig 345 | 346 | # BizTalk build output 347 | *.btp.cs 348 | *.btm.cs 349 | *.odx.cs 350 | *.xsd.cs 351 | 352 | # OpenCover UI analysis results 353 | OpenCover/ 354 | 355 | # Azure Stream Analytics local run output 356 | ASALocalRun/ 357 | 358 | # MSBuild Binary and Structured Log 359 | *.binlog 360 | 361 | # NVidia Nsight GPU debugger configuration file 362 | *.nvuser 363 | 364 | # MFractors (Xamarin productivity tool) working folder 365 | .mfractor/ 366 | 367 | # Local History for Visual Studio 368 | .localhistory/ 369 | 370 | # Visual Studio History (VSHistory) files 371 | .vshistory/ 372 | 373 | # BeatPulse healthcheck temp database 374 | healthchecksdb 375 | 376 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 377 | MigrationBackup/ 378 | 379 | # Ionide (cross platform F# VS Code tools) working folder 380 | .ionide/ 381 | 382 | # Fody - auto-generated XML schema 383 | FodyWeavers.xsd 384 | 385 | # VS Code files for those working on multiple tools 386 | .vscode/* 387 | !.vscode/settings.json 388 | !.vscode/tasks.json 389 | !.vscode/launch.json 390 | !.vscode/extensions.json 391 | *.code-workspace 392 | 393 | # Local History for Visual Studio Code 394 | .history/ 395 | 396 | # Windows Installer files from build outputs 397 | *.cab 398 | *.msi 399 | *.msix 400 | *.msm 401 | *.msp 402 | 403 | # JetBrains Rider 404 | *.sln.iml 405 | 406 | # macOS Desktop Services Store 407 | .DS_Store 408 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project has adopted the code of conduct defined by the Contributor Covenant 4 | to clarify expected behavior in our community. 5 | For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). 6 | -------------------------------------------------------------------------------- /IssueLabeler/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | net9.0 6 | true 7 | true 8 | $(MSBuildThisFileDirectory)artifacts 9 | 10 | 11 | 12 | 13 | root 14 | 15 | 16 | -------------------------------------------------------------------------------- /IssueLabeler/Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /IssueLabeler/IssueLabeler.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "src\Common\Common.csproj", "{3F3044DC-A9F8-DE16-79DD-4A0C1649CD06}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Downloader", "src\Downloader\Downloader.csproj", "{AB75FE13-DB1A-4B6F-8B27-1486F98EA75C}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Trainer", "src\Trainer\Trainer.csproj", "{F1FE4054-C44E-487F-90F9-2F111AB7BD9C}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Predictor", "src\Predictor\Predictor.csproj", "{2E39B0A5-2F4A-4D6E-8A0D-0366238CB21E}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tester", "src\Tester\Tester.csproj", "{BEA133F4-5686-49DF-83E4-641C26B3CC25}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHubClient", "src\GitHubClient\GitHubClient.csproj", "{57F2D1DC-DA30-40CA-AE1A-2EFD8139AF25}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{A2C54AC3-3D94-4CD3-885E-D1892063CC58}" 19 | ProjectSection(SolutionItems) = preProject 20 | .github\copilot-instructions.md = .github\copilot-instructions.md 21 | EndProjectSection 22 | EndProject 23 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{43E392F7-70F3-471D-A96A-E413E4387CA6}" 24 | ProjectSection(SolutionItems) = preProject 25 | ..\.github\workflows\build.yml = ..\.github\workflows\build.yml 26 | ..\.github\workflows\labeler-cache-retention.yml = ..\.github\workflows\labeler-cache-retention.yml 27 | ..\.github\workflows\labeler-predict-issues.yml = ..\.github\workflows\labeler-predict-issues.yml 28 | ..\.github\workflows\labeler-predict-pulls.yml = ..\.github\workflows\labeler-predict-pulls.yml 29 | ..\.github\workflows\labeler-promote.yml = ..\.github\workflows\labeler-promote.yml 30 | ..\.github\workflows\labeler-train.yml = ..\.github\workflows\labeler-train.yml 31 | ..\.github\workflows\release.yml = ..\.github\workflows\release.yml 32 | EndProjectSection 33 | EndProject 34 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Tests", "tests\Common.Tests\Common.Tests.csproj", "{D3F816D3-5CAE-4CF1-8977-F92AE96B481B}" 35 | EndProject 36 | Global 37 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 38 | Debug|Any CPU = Debug|Any CPU 39 | Release|Any CPU = Release|Any CPU 40 | EndGlobalSection 41 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 42 | {3F3044DC-A9F8-DE16-79DD-4A0C1649CD06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {3F3044DC-A9F8-DE16-79DD-4A0C1649CD06}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {3F3044DC-A9F8-DE16-79DD-4A0C1649CD06}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {3F3044DC-A9F8-DE16-79DD-4A0C1649CD06}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {AB75FE13-DB1A-4B6F-8B27-1486F98EA75C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {AB75FE13-DB1A-4B6F-8B27-1486F98EA75C}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {AB75FE13-DB1A-4B6F-8B27-1486F98EA75C}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {AB75FE13-DB1A-4B6F-8B27-1486F98EA75C}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {F1FE4054-C44E-487F-90F9-2F111AB7BD9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {F1FE4054-C44E-487F-90F9-2F111AB7BD9C}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {F1FE4054-C44E-487F-90F9-2F111AB7BD9C}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {F1FE4054-C44E-487F-90F9-2F111AB7BD9C}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {2E39B0A5-2F4A-4D6E-8A0D-0366238CB21E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {2E39B0A5-2F4A-4D6E-8A0D-0366238CB21E}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {2E39B0A5-2F4A-4D6E-8A0D-0366238CB21E}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {2E39B0A5-2F4A-4D6E-8A0D-0366238CB21E}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {BEA133F4-5686-49DF-83E4-641C26B3CC25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {BEA133F4-5686-49DF-83E4-641C26B3CC25}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {BEA133F4-5686-49DF-83E4-641C26B3CC25}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {BEA133F4-5686-49DF-83E4-641C26B3CC25}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {57F2D1DC-DA30-40CA-AE1A-2EFD8139AF25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {57F2D1DC-DA30-40CA-AE1A-2EFD8139AF25}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {57F2D1DC-DA30-40CA-AE1A-2EFD8139AF25}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {57F2D1DC-DA30-40CA-AE1A-2EFD8139AF25}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 67 | {D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Debug|Any CPU.Build.0 = Debug|Any CPU 68 | {D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Release|Any CPU.Build.0 = Release|Any CPU 70 | EndGlobalSection 71 | GlobalSection(SolutionProperties) = preSolution 72 | HideSolutionNode = FALSE 73 | EndGlobalSection 74 | GlobalSection(NestedProjects) = preSolution 75 | {43E392F7-70F3-471D-A96A-E413E4387CA6} = {A2C54AC3-3D94-4CD3-885E-D1892063CC58} 76 | EndGlobalSection 77 | GlobalSection(ExtensibilityGlobals) = postSolution 78 | SolutionGuid = {7B6C4568-423B-42F4-B527-042BBBF60D77} 79 | EndGlobalSection 80 | EndGlobal 81 | -------------------------------------------------------------------------------- /IssueLabeler/NuGet.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/App.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Actions.Core.Markdown; 5 | using Actions.Core.Services; 6 | 7 | /// 8 | /// This class contains methods to run tasks and handle exceptions. 9 | /// 10 | public static class App 11 | { 12 | /// 13 | /// Runs a list of tasks, catching and handling exceptions by logging them to the action's output and summary. 14 | /// 15 | /// Upon completion, the persistent summary is written. 16 | /// The list of tasks to run, waiting for all tasks to complete. 17 | /// The GitHub action service. 18 | /// A boolean indicating whether all tasks were completed successfully. 19 | public async static Task RunTasks(List tasks, ICoreService action) 20 | { 21 | var allTasks = Task.WhenAll(tasks); 22 | var success = await RunTasks(allTasks, action); 23 | 24 | return success; 25 | } 26 | 27 | /// 28 | /// Runs a list of tasks, catching and handling exceptions by logging them to the action's output and summary. 29 | /// 30 | /// The Task result type. 31 | /// The list of tasks to run, waiting for all tasks to complete. 32 | /// The GitHub action service. 33 | /// A tuple containing the results of the tasks and a boolean indicating whether all tasks were completed successfully. 34 | public async static Task<(TResult[], bool)> RunTasks(List> tasks, ICoreService action) 35 | { 36 | var allTasks = Task.WhenAll(tasks); 37 | var success = await RunTasks(allTasks, action); 38 | 39 | return (allTasks.Result, success); 40 | } 41 | 42 | /// 43 | /// Runs a single task, catching and handling exceptions by logging them to the action's output and summary. 44 | /// 45 | /// The task to run, waiting for it to complete. 46 | /// The GitHub action service. 47 | /// A boolean indicating whether the task was completed successfully. 48 | private async static Task RunTasks(Task task, ICoreService action) 49 | { 50 | var success = false; 51 | 52 | try 53 | { 54 | task.Wait(); 55 | success = true; 56 | } 57 | catch (AggregateException ex) 58 | { 59 | action.WriteError($"Exception occurred: {ex.Message}"); 60 | 61 | action.Summary.AddPersistent(summary => 62 | { 63 | summary.AddAlert("Exception occurred", AlertType.Caution); 64 | summary.AddNewLine(); 65 | summary.AddNewLine(); 66 | summary.AddMarkdownCodeBlock(ex.Message); 67 | }); 68 | } 69 | 70 | await action.Summary.WritePersistentAsync(); 71 | return success; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/ArgUtils.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Text.RegularExpressions; 6 | using Actions.Core.Services; 7 | 8 | public class ArgUtils 9 | { 10 | private ICoreService action; 11 | private Action showUsage; 12 | private Queue? arguments { get; } 13 | 14 | /// 15 | /// Create an arguments utility class instance for a GitHub action, with input values retrieved from the GitHub action. 16 | /// 17 | /// The GitHub action service. 18 | /// A method to show usage information for the application. 19 | public ArgUtils(ICoreService action, Action showUsage) 20 | { 21 | this.action = action; 22 | this.showUsage = message => showUsage(message, action); 23 | } 24 | 25 | /// 26 | /// Create an arguments utility class instance for a GitHub action, with input values retrieved from a queue of command-line arguments. 27 | /// 28 | /// The GitHub action service. 29 | /// A method to show usage information for the application. 30 | /// The queue of command-line arguments to extract argument values from. 31 | public ArgUtils(ICoreService action, Action showUsage, Queue arguments) : this(action, showUsage) 32 | { 33 | this.arguments = arguments; 34 | } 35 | 36 | /// 37 | /// Gets the input string for the specified input. 38 | /// 39 | /// 40 | /// When running as a GitHub action, this method will retrieve the input value from the action's inputs. 41 | /// 42 | /// 43 | /// When using the constructor with a queue of command-line arguments, this method will dequeue the next argument from the queue. 44 | /// 45 | /// The name of the input to retrieve. 46 | /// A nullable string containing the input value if retrieved, or null if there is no value specified. 47 | private string? GetInputString(string inputName) 48 | { 49 | string? input = null; 50 | 51 | if (arguments is not null) 52 | { 53 | if (arguments.TryDequeue(out string? argValue)) 54 | { 55 | input = argValue; 56 | } 57 | } 58 | else 59 | { 60 | input = action.GetInput(inputName); 61 | } 62 | 63 | return string.IsNullOrWhiteSpace(input) ? null : input; 64 | } 65 | 66 | /// 67 | /// Try to get a string input value, guarding against null values. 68 | /// 69 | /// The name of the input to retrieve. 70 | /// The output string value if retrieved, or null if there is no value specified or it was empty. 71 | /// true if the input value was retrieved successfully, false otherwise. 72 | public bool TryGetString(string inputName, [NotNullWhen(true)] out string? value) 73 | { 74 | value = GetInputString(inputName); 75 | return value is not null; 76 | } 77 | 78 | /// 79 | /// Determine if the specified flag is provided and set to true. 80 | /// 81 | /// The name of the flag to retrieve. 82 | /// true if the flag is provided and set to true, false otherwise. 83 | /// A boolean indicating if the flag was checked successfully, only returning false if specified as an invalid value. 84 | public bool TryGetFlag(string inputName, [NotNullWhen(true)] out bool? value) 85 | { 86 | string? input = GetInputString(inputName); 87 | 88 | if (input is null) 89 | { 90 | value = false; 91 | return true; 92 | } 93 | 94 | if (!bool.TryParse(input, out bool parsedValue)) 95 | { 96 | showUsage($"Input '{inputName}' must be 'true', 'false', 'TRUE', or 'FALSE'."); 97 | value = null; 98 | return false; 99 | } 100 | 101 | value = parsedValue; 102 | return true; 103 | } 104 | 105 | /// 106 | /// Try to get the GitHub repository name from the input or environment variable. 107 | /// 108 | /// 109 | /// Defaults to the GITHUB_REPOSITORY environment variable if the input is not specified. 110 | /// 111 | /// The name of the input to retrieve. 112 | /// The GitHub organization name, extracted from the specified {org}/{repo} value. 113 | /// The GitHub repository name, extracted from the specified {org}/{repo} value. 114 | /// true if the input value was retrieved successfully, false otherwise. 115 | public bool TryGetRepo(string inputName, [NotNullWhen(true)] out string? org, [NotNullWhen(true)] out string? repo) 116 | { 117 | string? orgRepo = GetInputString(inputName) ?? Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); 118 | 119 | if (orgRepo is null || !orgRepo.Contains('/')) 120 | { 121 | showUsage($$"""Input '{{inputName}}' has an empty value or is not in the format of '{org}/{repo}'. Value defaults to GITHUB_REPOSITORY environment variable if not specified."""); 122 | org = null; 123 | repo = null; 124 | return false; 125 | } 126 | 127 | string[] parts = orgRepo.Split('/'); 128 | org = parts[0]; 129 | repo = parts[1]; 130 | return true; 131 | } 132 | 133 | /// 134 | /// Try to get the GitHub repository list from the input or environment variable. 135 | /// 136 | /// 137 | /// Defaults to the GITHUB_REPOSITORY environment variable if the input is not specified. 138 | /// 139 | /// 140 | /// All repositories must be from the same organization. 141 | /// 142 | /// The name of the input to retrieve. 143 | /// The GitHub organization name, extracted from the specified {org}/{repo} value. 144 | /// The list of GitHub repository names, extracted from the specified {org}/{repo} value. 145 | /// true if the input value was retrieved successfully, false otherwise. 146 | public bool TryGetRepoList(string inputName, [NotNullWhen(true)] out string? org, [NotNullWhen(true)] out List? repos) 147 | { 148 | string? orgRepos = GetInputString(inputName) ?? Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); 149 | org = null; 150 | repos = null; 151 | 152 | if (orgRepos is null) 153 | { 154 | showUsage($$"""Input '{{inputName}}' has an empty value or is not in the format of '{org}/{repo}': {{orgRepos}}"""); 155 | return false; 156 | } 157 | 158 | foreach (var orgRepo in orgRepos.Split(',').Select(r => r.Trim())) 159 | { 160 | if (!orgRepo.Contains('/')) 161 | { 162 | showUsage($$"""Input '{{inputName}}' contains a value that is not in the format of '{org}/{repo}': {{orgRepo}}"""); 163 | return false; 164 | } 165 | 166 | string[] parts = orgRepo.Split('/'); 167 | 168 | if (org is not null && org != parts[0]) 169 | { 170 | showUsage($"All '{inputName}' values must be from the same org."); 171 | return false; 172 | } 173 | 174 | org ??= parts[0]; 175 | repos ??= []; 176 | repos.Add(parts[1]); 177 | } 178 | 179 | return (org is not null && repos is not null); 180 | } 181 | 182 | /// 183 | /// Try to get the label prefix from the input. 184 | /// 185 | /// 186 | /// The label prefix must end with a non-alphanumeric character. 187 | /// 188 | /// The name of the input to retrieve. 189 | /// The label predicate function that checks if a label starts with the specified prefix. 190 | /// true if the label prefix was retrieved successfully, false otherwise. 191 | public bool TryGetLabelPrefix(string inputName, [NotNullWhen(true)] out Func? labelPredicate) 192 | { 193 | string? labelPrefix = GetInputString(inputName); 194 | 195 | if (labelPrefix is null) 196 | { 197 | labelPredicate = null; 198 | return false; 199 | } 200 | 201 | // Require that the label prefix end in something other than a letter or number 202 | // This promotes the pattern of prefixes that are clear, rather than a prefix that 203 | // could be matched as the beginning of another word in the label 204 | if (Regex.IsMatch(labelPrefix.AsSpan(^1),"[a-zA-Z0-9]")) 205 | { 206 | showUsage($""" 207 | Input '{inputName}' must end in a non-alphanumeric character. 208 | 209 | The recommended label prefix terminating character is '-'. 210 | The recommended label prefix for applying area labels is 'area-'. 211 | """); 212 | 213 | labelPredicate = null; 214 | return false; 215 | } 216 | 217 | labelPredicate = (label) => label.StartsWith(labelPrefix, StringComparison.OrdinalIgnoreCase); 218 | return true; 219 | } 220 | 221 | /// 222 | /// Try to get a file path from the input. 223 | /// 224 | /// 225 | /// The file path is converted to an absolute path if it is not already absolute. 226 | /// 227 | /// The name of the input to retrieve. 228 | /// The output file path if retrieved, or null if there is no value specified. 229 | /// true if the input value was retrieved successfully, false otherwise. 230 | public bool TryGetPath(string inputName, out string? path) 231 | { 232 | path = GetInputString(inputName); 233 | 234 | if (path is null) 235 | { 236 | return false; 237 | } 238 | 239 | if (!Path.IsPathRooted(path)) 240 | { 241 | path = Path.GetFullPath(path); 242 | } 243 | 244 | return true; 245 | } 246 | 247 | /// 248 | /// Try to get a string array from the input. 249 | /// 250 | /// 251 | /// The string array is split by commas and trimmed of whitespace. 252 | /// 253 | /// The name of the input to retrieve. 254 | /// The output string array if retrieved, or null if there is no value specified. 255 | /// true if the input value was retrieved successfully, false otherwise. 256 | public bool TryGetStringArray(string inputName, [NotNullWhen(true)] out string[]? values) 257 | { 258 | string? input = GetInputString(inputName); 259 | 260 | if (input is null) 261 | { 262 | values = null; 263 | return false; 264 | } 265 | 266 | values = input.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); 267 | return true; 268 | } 269 | 270 | /// 271 | /// Try to get an integer from the input. 272 | /// 273 | /// The name of the input to retrieve. 274 | /// The output integer value if retrieved, or null if there is no value specified. 275 | /// true if the input value was retrieved successfully, false otherwise. 276 | public bool TryGetInt(string inputName, [NotNullWhen(true)] out int? value) => 277 | TryParseInt(inputName, GetInputString(inputName), out value); 278 | 279 | /// 280 | /// Try to parse an integer from the input string. 281 | /// 282 | /// The name of the input to retrieve. 283 | /// The input string to parse. 284 | /// The output integer value if parsed successfully, or null if the input is invalid. 285 | /// true if the input value was parsed successfully, false otherwise. 286 | private bool TryParseInt(string inputName, string? input, [NotNullWhen(true)] out int? value) 287 | { 288 | if (input is null || !int.TryParse(input, out int parsedValue)) 289 | { 290 | showUsage($"Input '{inputName}' must be an integer."); 291 | value = null; 292 | return false; 293 | } 294 | 295 | value = parsedValue; 296 | return true; 297 | } 298 | 299 | /// 300 | /// Try to get an integer array from the input. 301 | /// 302 | /// The name of the input to retrieve. 303 | /// The output integer array if retrieved, or null if there is no value specified. 304 | /// true if the input value was retrieved successfully, false otherwise. 305 | public bool TryGetIntArray(string inputName, [NotNullWhen(true)] out int[]? values) 306 | { 307 | string? input = GetInputString(inputName); 308 | 309 | if (input is not null) 310 | { 311 | string[] inputValues = input.Split(','); 312 | 313 | int[] parsedValues = inputValues.SelectMany(v => { 314 | if (!TryParseInt(inputName, v, out int? value)) 315 | { 316 | return new int[0]; 317 | } 318 | 319 | return [value.Value]; 320 | }).ToArray(); 321 | 322 | if (parsedValues.Length == inputValues.Length) 323 | { 324 | values = parsedValues; 325 | return true; 326 | } 327 | } 328 | 329 | values = null; 330 | return false; 331 | } 332 | 333 | /// 334 | /// Try to get a float from the input. 335 | /// 336 | /// The name of the input to retrieve. 337 | /// The output float value if retrieved, or null if there is no value specified. 338 | /// true if the input value was retrieved successfully, false otherwise. 339 | public bool TryGetFloat(string inputName, [NotNullWhen(true)] out float? value) 340 | { 341 | string? input = GetInputString(inputName); 342 | 343 | if (input is null || !float.TryParse(input, out float parsedValue)) 344 | { 345 | showUsage($"Input '{inputName}' must be a decimal value."); 346 | value = null; 347 | return false; 348 | } 349 | 350 | value = parsedValue; 351 | return true; 352 | } 353 | 354 | /// 355 | /// Try to get a list of number ranges from the input. 356 | /// 357 | /// 358 | /// The input is a comma-separated list of numbers and/or dash-separated ranges. 359 | /// 360 | /// The name of the input to retrieve. 361 | /// The output list of ulong values if retrieved, or null if there is no value specified. 362 | /// true if the input value was retrieved successfully, false otherwise. 363 | public bool TryGetNumberRanges(string inputName, [NotNullWhen(true)] out List? values) 364 | { 365 | string? input = GetInputString(inputName); 366 | 367 | if (input is not null) 368 | { 369 | var showUsageError = () => showUsage($"Input '{inputName}' must be comma-separated list of numbers and/or dash-separated ranges. Example: 1-3,5,7-9."); 370 | List numbers = []; 371 | 372 | foreach (var range in input.Split(',')) 373 | { 374 | var beginEnd = range.Split('-'); 375 | 376 | if (beginEnd.Length == 1) 377 | { 378 | if (!ulong.TryParse(beginEnd[0], out ulong number)) 379 | { 380 | showUsageError(); 381 | values = null; 382 | return false; 383 | } 384 | 385 | numbers.Add(number); 386 | } 387 | else if (beginEnd.Length == 2) 388 | { 389 | if (!ulong.TryParse(beginEnd[0], out ulong begin)) 390 | { 391 | showUsageError(); 392 | values = null; 393 | return false; 394 | } 395 | 396 | if (!ulong.TryParse(beginEnd[1], out ulong end)) 397 | { 398 | showUsageError(); 399 | values = null; 400 | return false; 401 | } 402 | 403 | for (var number = begin; number <= end; number++) 404 | { 405 | numbers.Add(number); 406 | } 407 | } 408 | else 409 | { 410 | showUsageError(); 411 | values = null; 412 | return false; 413 | } 414 | } 415 | 416 | values = numbers; 417 | return true; 418 | } 419 | 420 | values = null; 421 | return false; 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/DataFileUtils.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | /// 5 | /// Provides utility methods for handling data files. 6 | /// 7 | public static class DataFileUtils 8 | { 9 | /// 10 | /// Ensures that the directory for the specified output file exists, recursively creating it if necessary. 11 | /// 12 | /// The path of the output file. 13 | public static void EnsureOutputDirectory(string outputFile) 14 | { 15 | string? outputDir = Path.GetDirectoryName(outputFile); 16 | 17 | if (!string.IsNullOrEmpty(outputDir)) 18 | { 19 | Directory.CreateDirectory(outputDir); 20 | } 21 | } 22 | 23 | /// 24 | /// Sanitizes the specified text by replacing certain characters to ensure it is collapsed onto a single line and compatible with tab-separated values. 25 | /// 26 | /// The text to sanitize. 27 | /// The sanitized text. 28 | public static string SanitizeText(string text) 29 | => text 30 | .Replace('\r', ' ') 31 | .Replace('\n', ' ') 32 | .Replace('\t', ' ') 33 | .Replace('"', '`') 34 | .Trim(); 35 | 36 | /// 37 | /// Sanitizes an array of strings by joining them into a single space-separated string. 38 | /// 39 | /// The array of strings to sanitize. 40 | /// The sanitized text. 41 | public static string SanitizeTextArray(string[] texts) 42 | => string.Join(" ", texts.Select(SanitizeText)); 43 | 44 | /// 45 | /// Formats an issue record into a tab-separated string. 46 | /// 47 | /// The label of the issue. 48 | /// The title of the issue. 49 | /// The body of the issue. 50 | /// The formatted issue record. 51 | public static string FormatIssueRecord(string label, string title, string body) 52 | => string.Join('\t', 53 | [ 54 | SanitizeText(label), 55 | SanitizeText(title), 56 | SanitizeText(body) 57 | ]); 58 | 59 | /// 60 | /// Formats a pull request record into a tab-separated string. 61 | /// 62 | /// The label of the pull request. 63 | /// The title of the pull request. 64 | /// The body of the pull request. 65 | /// The array of file names associated with the pull request. 66 | /// The array of folder names associated with the pull request. 67 | /// The formatted pull request record. 68 | public static string FormatPullRequestRecord(string label, string title, string body, string[] fileNames, string[] folderNames) 69 | => string.Join('\t', 70 | [ 71 | SanitizeText(label), 72 | SanitizeText(title), 73 | SanitizeText(body), 74 | SanitizeTextArray(fileNames), 75 | SanitizeTextArray(folderNames) 76 | ]); 77 | } 78 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/GitHubActionSummary.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Actions.Core.Summaries; 5 | 6 | namespace Actions.Core.Services; 7 | 8 | /// 9 | /// This class provides methods to manage the GitHub action summary. 10 | /// 11 | public static class GitHubActionSummary 12 | { 13 | private static List> persistentSummaryWrites = []; 14 | 15 | /// 16 | /// Add persistent writes to the GitHub action summary, emitting them immediately 17 | /// and storing them for future rewrites when the summary is updated. 18 | /// 19 | /// The GitHub action summary. 20 | /// The invocation that results in adding content to the summary, to be replayed whenever the persistent summary is rewritten. 21 | public static void AddPersistent(this Summary summary, Action writeToSummary) 22 | { 23 | persistentSummaryWrites.Add(writeToSummary); 24 | writeToSummary(summary); 25 | } 26 | 27 | /// 28 | /// Writes a status message to the GitHub action summary and emits it immediately, always printing 29 | /// the status at the top of the summary, with other persistent writes below it. 30 | /// 31 | /// The GitHub action service. 32 | /// The status message to write. 33 | /// The async task. 34 | public static async Task WriteStatusAsync(this ICoreService action, string message) 35 | { 36 | action.WriteInfo(message); 37 | 38 | await action.Summary.WritePersistentAsync(summary => 39 | { 40 | summary.AddMarkdownHeading("Status", 3); 41 | summary.AddRaw(message); 42 | 43 | if (persistentSummaryWrites.Any()) 44 | { 45 | summary.AddMarkdownHeading("Results", 3); 46 | } 47 | }); 48 | } 49 | 50 | /// 51 | /// Writes the persistent summary to the GitHub action summary, clearing it first. 52 | /// 53 | /// The GitHub action summary. 54 | /// An optional action to write a status message to the summary. 55 | /// The async task. 56 | public static async Task WritePersistentAsync(this Summary summary, Action? writeStatus = null) 57 | { 58 | await summary.ClearAsync(); 59 | 60 | if (writeStatus is not null) 61 | { 62 | writeStatus(summary); 63 | } 64 | 65 | foreach (var write in persistentSummaryWrites) 66 | { 67 | write(summary); 68 | } 69 | 70 | await summary.WriteAsync(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /IssueLabeler/src/Common/ModelType.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | public enum ModelType 5 | { 6 | Issue = 1, 7 | PullRequest = 2 8 | } 9 | -------------------------------------------------------------------------------- /IssueLabeler/src/Downloader/Args.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Actions.Core.Services; 5 | 6 | public struct Args 7 | { 8 | public readonly string GitHubToken => Environment.GetEnvironmentVariable("GITHUB_TOKEN")!; 9 | public string Org { get; set; } 10 | public List Repos { get; set; } 11 | public string? IssuesDataPath { get; set; } 12 | public int? IssuesLimit { get; set; } 13 | public string? PullsDataPath { get; set; } 14 | public int? PullsLimit { get; set; } 15 | public int? PageSize { get; set; } 16 | public int? PageLimit { get; set; } 17 | public int[] Retries { get; set; } 18 | public string[]? ExcludedAuthors { get; set; } 19 | public Predicate LabelPredicate { get; set; } 20 | public bool Verbose { get; set; } 21 | 22 | static void ShowUsage(string? message, ICoreService action) 23 | { 24 | action.WriteNotice($$""" 25 | ERROR: Invalid or missing arguments.{{(message is null ? "" : " " + message)}} 26 | 27 | Required environment variables: 28 | GITHUB_TOKEN GitHub token to be used for API calls. 29 | 30 | Required arguments: 31 | --repo The GitHub repositories in format org/repo (comma separated for multiple). 32 | --label-prefix Prefix for label predictions. Must end with a character other than a letter or number. 33 | 34 | Required for downloading issue data: 35 | --issues-data Path for issue data file to create (TSV file). 36 | 37 | Required for downloading pull request data: 38 | --pulls-data Path for pull request data file to create (TSV file). 39 | 40 | Optional arguments: 41 | --issues-limit Maximum number of issues to download. Defaults to: No limit. 42 | --pulls-limit Maximum number of pull requests to download. Defaults to: No limit. 43 | --page-size Number of items per page in GitHub API requests. 44 | --page-limit Maximum number of pages to retrieve. 45 | --excluded-authors Comma-separated list of authors to exclude. 46 | --retries Comma-separated retry delays in seconds. 47 | Defaults to: 30,30,300,300,3000,3000. 48 | --verbose Enable verbose output. 49 | """); 50 | 51 | Environment.Exit(1); 52 | } 53 | 54 | public static Args? Parse(string[] args, ICoreService action) 55 | { 56 | Queue arguments = new(args); 57 | ArgUtils argUtils = new(action, ShowUsage, arguments); 58 | 59 | Args argsData = new() 60 | { 61 | Retries = [30, 30, 300, 300, 3000, 3000] 62 | }; 63 | 64 | if (string.IsNullOrEmpty(argsData.GitHubToken)) 65 | { 66 | ShowUsage("Environment variable GITHUB_TOKEN is empty.", action); 67 | return null; 68 | } 69 | 70 | while (arguments.Count > 0) 71 | { 72 | string argument = arguments.Dequeue(); 73 | 74 | switch (argument) 75 | { 76 | case "--repo": 77 | if (!argUtils.TryGetRepoList("--repo", out string? org, out List? repos)) 78 | { 79 | return null; 80 | } 81 | argsData.Org = org; 82 | argsData.Repos = repos; 83 | break; 84 | 85 | case "--label-prefix": 86 | if (!argUtils.TryGetLabelPrefix("--label-prefix", out Func? labelPredicate)) 87 | { 88 | return null; 89 | } 90 | argsData.LabelPredicate = new(labelPredicate); 91 | break; 92 | 93 | case "--excluded-authors": 94 | if (!argUtils.TryGetStringArray("--excluded-authors", out string[]? excludedAuthors)) 95 | { 96 | return null; 97 | } 98 | argsData.ExcludedAuthors = excludedAuthors; 99 | break; 100 | 101 | case "--issues-data": 102 | if (!argUtils.TryGetPath("--issues-data", out string? IssuesDataPath)) 103 | { 104 | return null; 105 | } 106 | argsData.IssuesDataPath = IssuesDataPath; 107 | break; 108 | 109 | case "--issues-limit": 110 | if (!argUtils.TryGetInt("--issues-limit", out int? IssuesLimit)) 111 | { 112 | return null; 113 | } 114 | argsData.IssuesLimit = IssuesLimit; 115 | break; 116 | 117 | case "--pulls-data": 118 | if (!argUtils.TryGetPath("--pulls-data", out string? PullsDataPath)) 119 | { 120 | return null; 121 | } 122 | argsData.PullsDataPath = PullsDataPath; 123 | break; 124 | 125 | case "--pulls-limit": 126 | if (!argUtils.TryGetInt("--pulls-limit", out int? PullsLimit)) 127 | { 128 | return null; 129 | } 130 | argsData.PullsLimit = PullsLimit; 131 | break; 132 | 133 | case "--page-size": 134 | if (!argUtils.TryGetInt("--page-size", out int? pageSize)) 135 | { 136 | return null; 137 | } 138 | argsData.PageSize = pageSize; 139 | break; 140 | 141 | case "--page-limit": 142 | if (!argUtils.TryGetInt("--page-limit", out int? pageLimit)) 143 | { 144 | return null; 145 | } 146 | argsData.PageLimit = pageLimit; 147 | break; 148 | 149 | case "--retries": 150 | if (!argUtils.TryGetIntArray("--retries", out int[]? retries)) 151 | { 152 | return null; 153 | } 154 | argsData.Retries = retries; 155 | break; 156 | 157 | case "--verbose": 158 | argsData.Verbose = true; 159 | break; 160 | 161 | default: 162 | ShowUsage($"Unrecognized argument: {argument}", action); 163 | return null; 164 | } 165 | } 166 | 167 | if (argsData.Org is null || argsData.Repos is null || argsData.LabelPredicate is null || 168 | (argsData.IssuesDataPath is null && argsData.PullsDataPath is null)) 169 | { 170 | ShowUsage(null, action); 171 | return null; 172 | } 173 | 174 | return argsData; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /IssueLabeler/src/Downloader/Downloader.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Actions.Core.Extensions; 5 | using Actions.Core.Services; 6 | using GitHubClient; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using static DataFileUtils; 9 | 10 | using var provider = new ServiceCollection() 11 | .AddGitHubActionsCore() 12 | .BuildServiceProvider(); 13 | 14 | var action = provider.GetRequiredService(); 15 | if (Args.Parse(args, action) is not Args argsData) return 1; 16 | 17 | List tasks = []; 18 | 19 | if (!string.IsNullOrEmpty(argsData.IssuesDataPath)) 20 | { 21 | EnsureOutputDirectory(argsData.IssuesDataPath); 22 | tasks.Add(Task.Run(() => DownloadIssues(argsData.IssuesDataPath))); 23 | } 24 | 25 | if (!string.IsNullOrEmpty(argsData.PullsDataPath)) 26 | { 27 | EnsureOutputDirectory(argsData.PullsDataPath); 28 | tasks.Add(Task.Run(() => DownloadPullRequests(argsData.PullsDataPath))); 29 | } 30 | 31 | var success = await App.RunTasks(tasks, action); 32 | return success ? 0 : 1; 33 | 34 | async Task DownloadIssues(string outputPath) 35 | { 36 | action.WriteInfo($"Issues Data Path: {outputPath}"); 37 | 38 | byte perFlushCount = 0; 39 | 40 | using StreamWriter writer = new StreamWriter(outputPath); 41 | writer.WriteLine(FormatIssueRecord("Label", "Title", "Body")); 42 | 43 | foreach (var repo in argsData.Repos) 44 | { 45 | await foreach (var result in GitHubApi.DownloadIssues(argsData.GitHubToken, argsData.Org, repo, argsData.LabelPredicate, 46 | argsData.IssuesLimit, argsData.PageSize, argsData.PageLimit, 47 | argsData.Retries, argsData.ExcludedAuthors, action, argsData.Verbose)) 48 | { 49 | writer.WriteLine(FormatIssueRecord(result.Label, result.Issue.Title, result.Issue.Body)); 50 | 51 | if (++perFlushCount == 100) 52 | { 53 | writer.Flush(); 54 | perFlushCount = 0; 55 | } 56 | } 57 | } 58 | 59 | writer.Close(); 60 | } 61 | 62 | async Task DownloadPullRequests(string outputPath) 63 | { 64 | action.WriteInfo($"Pulls Data Path: {outputPath}"); 65 | 66 | byte perFlushCount = 0; 67 | 68 | using StreamWriter writer = new StreamWriter(outputPath); 69 | writer.WriteLine(FormatPullRequestRecord("Label", "Title", "Body", ["FileNames"], ["FolderNames"])); 70 | 71 | foreach (var repo in argsData.Repos) 72 | { 73 | await foreach (var result in GitHubApi.DownloadPullRequests(argsData.GitHubToken, argsData.Org, repo, argsData.LabelPredicate, 74 | argsData.PullsLimit, argsData.PageSize, argsData.PageLimit, 75 | argsData.Retries, argsData.ExcludedAuthors, action, argsData.Verbose)) 76 | { 77 | writer.WriteLine(FormatPullRequestRecord(result.Label, result.PullRequest.Title, result.PullRequest.Body, result.PullRequest.FileNames, result.PullRequest.FolderNames)); 78 | 79 | if (++perFlushCount == 100) 80 | { 81 | writer.Flush(); 82 | perFlushCount = 0; 83 | } 84 | } 85 | } 86 | 87 | writer.Close(); 88 | } 89 | -------------------------------------------------------------------------------- /IssueLabeler/src/Downloader/Downloader.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /IssueLabeler/src/GitHubClient/GitHubApi.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using Actions.Core.Services; 5 | using GraphQL; 6 | using GraphQL.Client.Http; 7 | using GraphQL.Client.Serializer.SystemTextJson; 8 | using System.Collections.Concurrent; 9 | using System.Net.Http.Json; 10 | 11 | namespace GitHubClient; 12 | 13 | public class GitHubApi 14 | { 15 | private static ConcurrentDictionary _graphQLClients = new(); 16 | private static ConcurrentDictionary _restClients = new(); 17 | private const int MaxLabelDelaySeconds = 30; 18 | 19 | /// 20 | /// Gets or creates a GraphQL client for the GitHub API using the provided token. 21 | /// 22 | /// The timeout is set to 2 minutes and the client is cached for reuse. 23 | /// The GitHub token to use for authentication. 24 | /// A GraphQLHttpClient instance configured with the provided token and necessary headers. 25 | private static GraphQLHttpClient GetGraphQLClient(string githubToken) => 26 | _graphQLClients.GetOrAdd(githubToken, token => 27 | { 28 | GraphQLHttpClient client = new("https://api.github.com/graphql", new SystemTextJsonSerializer()); 29 | client.HttpClient.DefaultRequestHeaders.Authorization = 30 | new System.Net.Http.Headers.AuthenticationHeaderValue( 31 | scheme: "bearer", 32 | parameter: token); 33 | 34 | client.HttpClient.Timeout = TimeSpan.FromMinutes(2); 35 | 36 | return client; 37 | }); 38 | 39 | /// 40 | /// Gets or creates a REST client for the GitHub API using the provided token. 41 | /// 42 | /// The client is cached for reuse. 43 | /// The GitHub token to use for authentication. 44 | /// An HttpClient instance configured with the provided token and necessary headers. 45 | private static HttpClient GetRestClient(string githubToken) => 46 | _restClients.GetOrAdd(githubToken, token => 47 | { 48 | HttpClient client = new(); 49 | client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( 50 | scheme: "bearer", 51 | parameter: token); 52 | client.DefaultRequestHeaders.Accept.Add(new("application/vnd.github+json")); 53 | client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); 54 | client.DefaultRequestHeaders.Add("User-Agent", "Issue-Labeler"); 55 | 56 | return client; 57 | }); 58 | 59 | /// 60 | /// Downloads issues from a GitHub repository, filtering them by label and other criteria. 61 | /// 62 | /// The GitHub token to use for authentication. 63 | /// The GitHub organization name. 64 | /// The GitHub repository name. 65 | /// A predicate function to filter labels. 66 | /// The maximum number of issues to download. 67 | /// The number of items per page in GitHub API requests. 68 | /// The maximum number of pages to retrieve. 69 | /// An array of retry delays in seconds. 70 | /// An array of authors to exclude from the results. 71 | /// The GitHub action service. 72 | /// Emit verbose output into the action log. 73 | /// The downloaded issues as an async enumerable collection of tuples containing the issue and its predicate-matched label (when only one matcing label is found). 74 | public static async IAsyncEnumerable<(Issue Issue, string Label)> DownloadIssues( 75 | string githubToken, 76 | string org, string repo, 77 | Predicate labelPredicate, 78 | int? issuesLimit, 79 | int? pageSize, 80 | int? pageLimit, 81 | int[] retries, 82 | string[]? excludedAuthors, 83 | ICoreService action, 84 | bool verbose = false) 85 | { 86 | await foreach (var item in DownloadItems("issues", githubToken, org, repo, labelPredicate, issuesLimit, pageSize ?? 100, pageLimit ?? 1000, retries, excludedAuthors, action, verbose)) 87 | { 88 | yield return (item.Item, item.Label); 89 | } 90 | } 91 | 92 | /// 93 | /// Downloads pull requests from a GitHub repository, filtering them by label and other criteria. 94 | /// 95 | /// The GitHub token to use for authentication. 96 | /// The GitHub organization name. 97 | /// The GitHub repository name. 98 | /// A predicate function to filter labels. 99 | /// The maximum number of pull requests to download. 100 | /// The number of items per page in GitHub API requests. 101 | /// The maximum number of pages to retrieve. 102 | /// An array of retry delays in seconds. 103 | /// An array of authors to exclude from the results. 104 | /// The GitHub action service. 105 | /// Emit verbose output into the action log. 106 | /// The downloaded pull requests as an async enumerable collection of tuples containing the pull request and its predicate-matched label (when only one matching label is found). 107 | public static async IAsyncEnumerable<(PullRequest PullRequest, string Label)> DownloadPullRequests( 108 | string githubToken, 109 | string org, 110 | string repo, 111 | Predicate labelPredicate, 112 | int? pullsLimit, 113 | int? pageSize, 114 | int? pageLimit, 115 | int[] retries, 116 | string[]? excludedAuthors, 117 | ICoreService action, 118 | bool verbose = false) 119 | { 120 | var items = DownloadItems("pullRequests", githubToken, org, repo, labelPredicate, pullsLimit, pageSize ?? 25, pageLimit ?? 4000, retries, excludedAuthors, action, verbose); 121 | 122 | await foreach (var item in items) 123 | { 124 | yield return (item.Item, item.Label); 125 | } 126 | } 127 | 128 | /// 129 | /// Downloads items from a GitHub repository, filtering them by label and other criteria. 130 | /// 131 | /// 132 | /// The GraphQL query name for the item type (e.g., "issues" or "pullRequests"). 133 | /// The GitHub token to use for authentication. 134 | /// The GitHub organization name. 135 | /// The GitHub repository name. 136 | /// A predicate function to filter labels. 137 | /// The maximum number of issues to download. 138 | /// The number of items per page in GitHub API requests. 139 | /// The maximum number of pages to retrieve. 140 | /// An array of retry delays in seconds. 141 | /// An array of authors to exclude from the results. 142 | /// The GitHub action service. 143 | /// Emit verbose output into the action log. 144 | /// The downloaded items as an async enumerable collection of tuples containing the item and its predicate-matched label (when only one matching label is found). 145 | /// 146 | private static async IAsyncEnumerable<(T Item, string Label)> DownloadItems( 147 | string itemQueryName, 148 | string githubToken, 149 | string org, 150 | string repo, 151 | Predicate labelPredicate, 152 | int? itemLimit, 153 | int pageSize, 154 | int pageLimit, 155 | int[] retries, 156 | string[]? excludedAuthors, 157 | ICoreService action, 158 | bool verbose) where T : Issue 159 | { 160 | pageSize = Math.Min(pageSize, 100); 161 | 162 | string typeNames = typeof(T) == typeof(PullRequest) ? "Pull Requests" : "Issues"; 163 | string typeName = typeof(T) == typeof(PullRequest) ? "Pull Request" : "Issue"; 164 | 165 | int pageNumber = 0; 166 | string? after = null; 167 | bool hasNextPage = true; 168 | int loadedCount = 0; 169 | int includedCount = 0; 170 | int? totalCount = null; 171 | byte retry = 0; 172 | bool finished = false; 173 | 174 | do 175 | { 176 | action.WriteInfo($"Downloading {typeNames} page {pageNumber + 1} from {org}/{repo}...{(retry > 0 ? $" (retry {retry} of {retries.Length}) " : "")}{(after is not null ? $" (cursor: '{after}')" : "")}"); 177 | 178 | Page page; 179 | 180 | try 181 | { 182 | page = await GetItemsPage(githubToken, org, repo, pageSize, after, itemQueryName); 183 | } 184 | catch (Exception ex) when ( 185 | ex is HttpIOException || 186 | ex is HttpRequestException || 187 | ex is GraphQLHttpRequestException || 188 | ex is TaskCanceledException 189 | ) 190 | { 191 | action.WriteInfo($"Exception caught during query.\n {ex.Message}"); 192 | 193 | if (retry >= retries.Length - 1) 194 | { 195 | await action.WriteStatusAsync($"Retry limit of {retries.Length} reached. Aborting."); 196 | 197 | throw new ApplicationException($""" 198 | Retry limit of {retries.Length} reached. Aborting. 199 | 200 | {ex.Message} 201 | 202 | Total Downloaded: {totalCount} 203 | Applicable for Training: {loadedCount} 204 | Page Number: {pageNumber} 205 | """ 206 | ); 207 | } 208 | else 209 | { 210 | await action.WriteStatusAsync($"Waiting {retries[retry]} seconds before retry {retry + 1} of {retries.Length}..."); 211 | await Task.Delay(retries[retry] * 1000); 212 | retry++; 213 | 214 | continue; 215 | } 216 | } 217 | 218 | if (after == page.EndCursor) 219 | { 220 | action.WriteError($"Paging did not progress. Cursor: '{after}'. Aborting."); 221 | break; 222 | } 223 | 224 | pageNumber++; 225 | after = page.EndCursor; 226 | hasNextPage = page.HasNextPage; 227 | loadedCount += page.Nodes.Length; 228 | totalCount ??= page.TotalCount; 229 | retry = 0; 230 | 231 | foreach (T item in page.Nodes) 232 | { 233 | if (excludedAuthors is not null && item.Author?.Login is not null && excludedAuthors.Contains(item.Author.Login, StringComparer.InvariantCultureIgnoreCase)) 234 | { 235 | if (verbose) action.WriteInfo($"{typeName} {org}/{repo}#{item.Number} - Excluded from output. Author '{item.Author.Login}' is in excluded list."); 236 | continue; 237 | } 238 | 239 | // If there are more labels, there might be other applicable 240 | // labels that were not loaded and the model is incomplete. 241 | if (item.Labels.HasNextPage) 242 | { 243 | if (verbose) action.WriteInfo($"{typeName} {org}/{repo}#{item.Number} - Excluded from output. Not all labels were loaded."); 244 | continue; 245 | } 246 | 247 | // Only items with exactly one applicable label are used for the model. 248 | string[] labels = Array.FindAll(item.LabelNames, labelPredicate); 249 | if (labels.Length != 1) 250 | { 251 | if (verbose) action.WriteInfo($"{typeName} {org}/{repo}#{item.Number} - Excluded from output. {labels.Length} applicable labels found."); 252 | continue; 253 | } 254 | 255 | // Exactly one applicable label was found on the item. Include it in the model. 256 | if (verbose) action.WriteInfo($"{typeName} {org}/{repo}#{item.Number} - Included in output. Applicable label: '{labels[0]}'."); 257 | 258 | yield return (item, labels[0]); 259 | 260 | includedCount++; 261 | 262 | if (itemLimit.HasValue && includedCount >= itemLimit) 263 | { 264 | break; 265 | } 266 | } 267 | 268 | finished = (!hasNextPage || pageNumber >= pageLimit || (itemLimit.HasValue && includedCount >= itemLimit)); 269 | 270 | await action.WriteStatusAsync( 271 | $"Items to Include: {includedCount} (limit: {(itemLimit.HasValue ? itemLimit : "none")}) | " + 272 | $"Items Downloaded: {loadedCount} (total: {totalCount}) | " + 273 | $"Pages Downloaded: {pageNumber} (limit: {pageLimit})"); 274 | 275 | if (finished) 276 | { 277 | action.Summary.AddPersistent(summary => { 278 | summary.AddMarkdownHeading($"Finished Downloading {typeNames} from {org}/{repo}", 2); 279 | summary.AddMarkdownList([ 280 | $"Items to Include: {includedCount} (limit: {(itemLimit.HasValue ? itemLimit : "none")})", 281 | $"Items Downloaded: {loadedCount} (total: {totalCount})", 282 | $"Pages Downloaded: {pageNumber} (limit: {pageLimit})" 283 | ]); 284 | }); 285 | } 286 | } 287 | while (!finished); 288 | } 289 | 290 | /// 291 | /// Retrieves a page of items from a GitHub repository using GraphQL. 292 | /// 293 | /// The type of items to retrieve (e.g., Issue or PullRequest). 294 | /// The GitHub token to use for authentication. 295 | /// The GitHub organization name. 296 | /// The GitHub repository name. 297 | /// The number of items per page in GitHub API requests. 298 | /// The cursor for pagination (null for the first page). 299 | /// The GraphQL query name for the item type (e.g., "issues" or "pullRequests"). 300 | /// The page of items retrieved from the GitHub repository. 301 | /// When the GraphQL request returns errors or the response does not include the expected data. 302 | private static async Task> GetItemsPage(string githubToken, string org, string repo, int pageSize, string? after, string itemQueryName) where T : Issue 303 | { 304 | GraphQLHttpClient client = GetGraphQLClient(githubToken); 305 | 306 | string files = typeof(T) == typeof(PullRequest) ? "files (first: 100) { nodes { path } }" : ""; 307 | 308 | GraphQLRequest query = new GraphQLRequest 309 | { 310 | Query = $$""" 311 | query ($owner: String!, $repo: String!, $after: String) { 312 | repository (owner: $owner, name: $repo) { 313 | result:{{itemQueryName}} (after: $after, first: {{pageSize}}, orderBy: {field: CREATED_AT, direction: DESC}) { 314 | nodes { 315 | number 316 | title 317 | author { login } 318 | body: bodyText 319 | labels (first: 25) { 320 | nodes { name }, 321 | pageInfo { hasNextPage } 322 | } 323 | {{files}} 324 | } 325 | pageInfo { 326 | hasNextPage 327 | endCursor 328 | } 329 | totalCount 330 | } 331 | } 332 | } 333 | """, 334 | Variables = new 335 | { 336 | Owner = org, 337 | Repo = repo, 338 | After = after 339 | } 340 | }; 341 | 342 | var response = await client.SendQueryAsync>>(query); 343 | 344 | if (response.Errors?.Any() ?? false) 345 | { 346 | string errors = string.Join("\n\n", response.Errors.Select((e, i) => $"{i + 1}. {e.Message}").ToArray()); 347 | throw new ApplicationException($"GraphQL request returned errors.\n\n{errors}"); 348 | } 349 | else if (response.Data is null || response.Data.Repository is null || response.Data.Repository.Result is null) 350 | { 351 | throw new ApplicationException("GraphQL response did not include the repository result data"); 352 | } 353 | 354 | return response.Data.Repository.Result; 355 | } 356 | 357 | /// 358 | /// Gets an issue from a GitHub repository using GraphQL. 359 | /// 360 | /// The GitHub token to use for authentication. 361 | /// The GitHub organization name. 362 | /// The GitHub repository name. 363 | /// The issue number. 364 | /// An array of retry delays in seconds. 365 | /// The GitHub action service. 366 | /// Emit verbose output into the action log. 367 | /// The issue retrieved from the GitHub repository, or null if not found. 368 | public static async Task GetIssue(string githubToken, string org, string repo, ulong number, int[] retries, ICoreService action, bool verbose) => 369 | await GetItem(githubToken, org, repo, number, retries, verbose, "issue", action); 370 | 371 | /// 372 | /// Gets a pull request from a GitHub repository using GraphQL. 373 | /// 374 | /// The GitHub token to use for authentication. 375 | /// The GitHub organization name. 376 | /// The GitHub repository name. 377 | /// The pull request number. 378 | /// An array of retry delays in seconds. 379 | /// The GitHub action service. 380 | /// Emit verbose output into the action log. 381 | /// The pull request retrieved from the GitHub repository, or null if not found. 382 | public static async Task GetPullRequest(string githubToken, string org, string repo, ulong number, int[] retries, ICoreService action, bool verbose) => 383 | await GetItem(githubToken, org, repo, number, retries, verbose, "pullRequest", action); 384 | 385 | private static async Task GetItem(string githubToken, string org, string repo, ulong number, int[] retries, bool verbose, string itemQueryName, ICoreService action) where T : Issue 386 | { 387 | GraphQLHttpClient client = GetGraphQLClient(githubToken); 388 | string files = typeof(T) == typeof(PullRequest) ? "files (first: 100) { nodes { path } }" : ""; 389 | 390 | GraphQLRequest query = new GraphQLRequest 391 | { 392 | Query = $$""" 393 | query ($owner: String!, $repo: String!, $number: Int!) { 394 | repository (owner: $owner, name: $repo) { 395 | result:{{itemQueryName}} (number: $number) { 396 | number 397 | title 398 | author { login } 399 | body: bodyText 400 | labels (first: 25) { 401 | nodes { name }, 402 | pageInfo { hasNextPage } 403 | } 404 | {{files}} 405 | } 406 | } 407 | } 408 | """, 409 | Variables = new 410 | { 411 | Owner = org, 412 | Repo = repo, 413 | Number = number 414 | } 415 | }; 416 | 417 | byte retry = 0; 418 | string typeName = typeof(T) == typeof(PullRequest) ? "Pull Request" : "Issue"; 419 | 420 | while (retry < retries.Length) 421 | { 422 | try 423 | { 424 | var response = await client.SendQueryAsync>(query); 425 | 426 | if (!(response.Errors?.Any() ?? false) && response.Data?.Repository?.Result is not null) 427 | { 428 | return response.Data.Repository.Result; 429 | } 430 | 431 | if (response.Errors?.Any() ?? false) 432 | { 433 | // These errors occur when an issue/pull does not exist or when the API rate limit has been exceeded 434 | if (response.Errors.Any(e => e.Message.StartsWith("API rate limit exceeded"))) 435 | { 436 | action.WriteInfo($""" 437 | [{typeName} {org}/{repo}#{number}] Failed to retrieve data. 438 | Rate limit has been reached. 439 | {(retry < retries.Length ? $"Will proceed with retry {retry + 1} of {retries.Length} after {retries[retry]} seconds..." : $"Retry limit of {retries.Length} reached.")} 440 | """); 441 | } 442 | else 443 | { 444 | // Could not detect this as a rate limit issue. Do not retry. 445 | string errors = string.Join("\n\n", response.Errors.Select((e, i) => $"{i + 1}. {e.Message}").ToArray()); 446 | 447 | action.WriteInfo($""" 448 | [{typeName} {org}/{repo}#{number}] Failed to retrieve data. 449 | GraphQL request returned errors: 450 | 451 | {errors} 452 | """); 453 | 454 | return null; 455 | } 456 | } 457 | else 458 | { 459 | // Do not retry as these errors are not recoverable 460 | // This is usually a bug during development when the query/response model is incorrect 461 | action.WriteInfo($""" 462 | [{typeName} {org}/{repo}#{number}] Failed to retrieve data. 463 | GraphQL response did not include the repository result data. 464 | """); 465 | 466 | return null; 467 | } 468 | } 469 | catch (Exception ex) when ( 470 | ex is HttpIOException || 471 | ex is HttpRequestException || 472 | ex is GraphQLHttpRequestException || 473 | ex is TaskCanceledException 474 | ) 475 | { 476 | // Retry on exceptions as they can be temporary network issues 477 | action.WriteInfo($""" 478 | [{typeName} {org}/{repo}#{number}] Failed to retrieve data. 479 | Exception caught during query. 480 | 481 | {ex.Message} 482 | 483 | {(retry < retries.Length ? $"Will proceed with retry {retry + 1} of {retries.Length} after {retries[retry]} seconds..." : $"Retry limit of {retries.Length} reached.")} 484 | """); 485 | } 486 | 487 | await Task.Delay(retries[retry++] * 1000); 488 | } 489 | 490 | return null; 491 | } 492 | 493 | /// 494 | /// Adds a label to an issue or pull request in a GitHub repository. 495 | /// 496 | /// The GitHub token to use for authentication. 497 | /// The GitHub organization name. 498 | /// The GitHub repository name. 499 | /// The type of item (e.g., "issue" or "pull request"). 500 | /// The issue or pull request number. 501 | /// The label to add. 502 | /// An array of retry delays in seconds. A maximum delay of 30 seconds is enforced. 503 | /// The GitHub action service. 504 | /// A string describing a failure, or null if successful. 505 | public static async Task AddLabel(string githubToken, string org, string repo, string type, ulong number, string label, int[] retries, ICoreService action) 506 | { 507 | var client = GetRestClient(githubToken); 508 | byte retry = 0; 509 | 510 | while (retry < retries.Length) 511 | { 512 | var response = await client.PostAsJsonAsync( 513 | $"https://api.github.com/repos/{org}/{repo}/issues/{number}/labels", 514 | new string[] { label }, 515 | CancellationToken.None); 516 | 517 | if (response.IsSuccessStatusCode) 518 | { 519 | return null; 520 | } 521 | 522 | action.WriteInfo($""" 523 | [{type} {org}/{repo}#{number}] Failed to add label '{label}'. {response.ReasonPhrase} ({response.StatusCode}) 524 | {(retry < retries.Length ? $"Will proceed with retry {retry + 1} of {retries.Length} after {retries[retry]} seconds..." : $"Retry limit of {retries.Length} reached.")} 525 | """); 526 | 527 | int delay = Math.Min(retries[retry++], MaxLabelDelaySeconds); 528 | await Task.Delay(delay * 1000); 529 | } 530 | 531 | return $"Failed to add label '{label}' after {retries.Length} retries."; 532 | } 533 | 534 | /// 535 | /// Removes a label from an issue or pull request in a GitHub repository. 536 | /// 537 | /// The GitHub token to use for authentication. 538 | /// The GitHub organization name. 539 | /// The GitHub repository name. 540 | /// The type of item (e.g., "issue" or "pull request"). 541 | /// The issue or pull request number. 542 | /// The label to add. 543 | /// An array of retry delays in seconds. A maximum delay of 30 seconds is enforced. 544 | /// The GitHub action service. 545 | /// A string describing a failure, or null if successful. 546 | public static async Task RemoveLabel(string githubToken, string org, string repo, string type, ulong number, string label, int[] retries, ICoreService action) 547 | { 548 | var client = GetRestClient(githubToken); 549 | byte retry = 0; 550 | 551 | while (retry < retries.Length) 552 | { 553 | var response = await client.DeleteAsync( 554 | $"https://api.github.com/repos/{org}/{repo}/issues/{number}/labels/{label}", 555 | CancellationToken.None); 556 | 557 | if (response.IsSuccessStatusCode) 558 | { 559 | return null; 560 | } 561 | 562 | action.WriteInfo($""" 563 | [{type} {org}/{repo}#{number}] Failed to remove label '{label}'. {response.ReasonPhrase} ({response.StatusCode}) 564 | {(retry < retries.Length ? $"Will proceed with retry {retry + 1} of {retries.Length} after {retries[retry]} seconds..." : $"Retry limit of {retries.Length} reached.")} 565 | """); 566 | 567 | int delay = Math.Min(retries[retry++], MaxLabelDelaySeconds); 568 | await Task.Delay(delay * 1000); 569 | } 570 | 571 | return $"Failed to remove label '{label}' after {retries.Length} retries."; 572 | } 573 | } 574 | -------------------------------------------------------------------------------- /IssueLabeler/src/GitHubClient/GitHubClient.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /IssueLabeler/src/GitHubClient/QueryModel.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | namespace GitHubClient; 5 | 6 | public class RepositoryQuery 7 | { 8 | public required RepositoryItems Repository { get; init; } 9 | 10 | public class RepositoryItems 11 | { 12 | public required T Result { get; init; } 13 | } 14 | } 15 | 16 | public class Author 17 | { 18 | public required string Login { get; init; } 19 | } 20 | 21 | public class Issue 22 | { 23 | public required ulong Number { get; init; } 24 | public required string Title { get; init; } 25 | public required Author Author { get; init; } 26 | public required string Body { get; init; } 27 | public required Page