├── .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