Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions .github/workflows/labeler-train.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ on:
description: "The cache key suffix to use for staged data/models (use 'ACTIVE' to bypass staging). Defaults to 'staged'."
required: true
default: "staged"
data_cache_key_suffix:
description: "For Test Model only: the original dataset suffix, if different from the model suffix (for example after promotion)."

permissions: {}

env:
CACHE_KEY: ${{ github.event.inputs.cache_key_suffix }}
DATA_CACHE_KEY: ${{ github.event.inputs.steps == 'Test Model' && github.event.inputs.data_cache_key_suffix || github.event.inputs.cache_key_suffix }}
REPOSITORY: ${{ github.event.inputs.repository || github.repository }}
LABEL_PREFIX: "area-"
EXCLUDED_LABELS: ${{ github.event.inputs.excluded_labels }}
Expand Down Expand Up @@ -130,8 +133,10 @@ jobs:
- name: "Restore Issue Data from Cache"
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: labeler-cache/issues-data.tsv
key: issue-labeler/data/issues/${{ env.CACHE_KEY }}
path: |
labeler-cache/issues-data.tsv
labeler-cache/issues-data.split.json
Comment thread
Copilot marked this conversation as resolved.
Outdated
key: issue-labeler/data/issues/${{ env.DATA_CACHE_KEY }}
fail-on-cache-miss: true

- name: "Check Issue Training Data"
Expand All @@ -158,8 +163,10 @@ jobs:
- name: "Restore Pull Request Data from Cache"
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: labeler-cache/pulls-data.tsv
key: issue-labeler/data/pulls/${{ env.CACHE_KEY }}
path: |
labeler-cache/pulls-data.tsv
labeler-cache/pulls-data.split.json
key: issue-labeler/data/pulls/${{ env.DATA_CACHE_KEY }}
fail-on-cache-miss: true

- name: "Check Pull Request Training Data"
Expand All @@ -186,8 +193,10 @@ jobs:
- name: "Restore Discussion Data from Cache"
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: labeler-cache/discussions-data.tsv
key: issue-labeler/data/discussions/${{ env.CACHE_KEY }}
path: |
labeler-cache/discussions-data.tsv
labeler-cache/discussions-data.split.json
key: issue-labeler/data/discussions/${{ env.DATA_CACHE_KEY }}
fail-on-cache-miss: true

- name: "Check Discussion Training Data"
Expand Down Expand Up @@ -262,6 +271,7 @@ jobs:
with:
type: "issues"
cache_key: ${{ env.CACHE_KEY }}
data_cache_key: ${{ env.DATA_CACHE_KEY }}
repository: ${{ env.REPOSITORY }}
label_prefix: ${{ env.LABEL_PREFIX }}
excluded_labels: ${{ env.EXCLUDED_LABELS }}
Expand All @@ -286,6 +296,7 @@ jobs:
with:
type: "discussions"
cache_key: ${{ env.CACHE_KEY }}
data_cache_key: ${{ env.DATA_CACHE_KEY }}
repository: ${{ env.REPOSITORY }}
label_prefix: ${{ env.LABEL_PREFIX }}
excluded_labels: ${{ env.EXCLUDED_LABELS }}
Expand All @@ -310,6 +321,7 @@ jobs:
with:
type: "pulls"
cache_key: ${{ env.CACHE_KEY }}
data_cache_key: ${{ env.DATA_CACHE_KEY }}
repository: ${{ env.REPOSITORY }}
label_prefix: ${{ env.LABEL_PREFIX }}
excluded_labels: ${{ env.EXCLUDED_LABELS }}
Expand Down
6 changes: 6 additions & 0 deletions IssueLabeler/IssueLabeler.sln
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Tests", "tests\Common.Tests\Common.Tests.csproj", "{D3F816D3-5CAE-4CF1-8977-F92AE96B481B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tester.Tests", "tests\Tester.Tests\Tester.Tests.csproj", "{BA177CD9-81D1-4143-A433-665B2699B361}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -67,6 +69,10 @@ Global
{D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3F816D3-5CAE-4CF1-8977-F92AE96B481B}.Release|Any CPU.Build.0 = Release|Any CPU
{BA177CD9-81D1-4143-A433-665B2699B361}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA177CD9-81D1-4143-A433-665B2699B361}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA177CD9-81D1-4143-A433-665B2699B361}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA177CD9-81D1-4143-A433-665B2699B361}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
4 changes: 2 additions & 2 deletions IssueLabeler/src/Common/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ public async static Task<bool> RunTasks(List<Task> tasks, ICoreService action)
/// <typeparam name="TResult">The Task result type.</typeparam>
/// <param name="tasks">The list of tasks to run, waiting for all tasks to complete.</param>
/// <param name="action">The GitHub action service.</param>
/// <returns>A tuple containing the results of the tasks and a boolean indicating whether all tasks were completed successfully.</returns>
/// <returns>A tuple containing the results of successfully completed tasks and a boolean indicating whether all tasks were completed successfully.</returns>
public async static Task<(TResult[], bool)> RunTasks<TResult>(List<Task<TResult>> tasks, ICoreService action)
{
var allTasks = Task.WhenAll(tasks);
var success = await RunTasks(allTasks, action);

return (allTasks.Result, success);
return (tasks.Where(task => task.IsCompletedSuccessfully).Select(task => task.Result).ToArray(), success);
}

/// <summary>
Expand Down
190 changes: 190 additions & 0 deletions IssueLabeler/src/Common/DatasetSplit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class DatasetSplit
{
private const string ModelReferenceEntry = "DatasetSplit.sha256";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
RespectNullableAnnotations = true
};
private string fingerprint = string.Empty;

[JsonRequired]
public int SchemaVersion { get; init; } = 1;
public required string DatasetSha256 { get; init; }
[JsonRequired]
public int ColumnCount { get; init; }
[JsonRequired]
public int RowCount { get; init; }
public required int[] TestRows { get; init; }

public static string GetPath(string dataPath) => Path.ChangeExtension(dataPath, ".split.json");

public static DatasetSplit Create(string dataPath)
{
int columnCount = ReadHeader(dataPath).Length;
int rowCount = ReadRecords(dataPath).Count();
int testCount = rowCount < 2 ? 0 : Math.Clamp((int)Math.Round(rowCount * 0.2), 1, rowCount - 1);
int[] indices = [.. Enumerable.Range(0, rowCount)];
new Random(42).Shuffle(indices);
var split = new DatasetSplit
{
DatasetSha256 = HashFile(dataPath),
ColumnCount = columnCount,
RowCount = rowCount,
TestRows = [.. indices.Take(testCount).Order()]
};
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(split, JsonOptions);
File.WriteAllBytes(GetPath(dataPath), bytes);
split.fingerprint = Convert.ToHexStringLower(SHA256.HashData(bytes));
return split;
}

public static DatasetSplit Load(string dataPath)
{
if (!File.Exists(dataPath) || !File.Exists(GetPath(dataPath)))
{
throw new FileNotFoundException(
"The original dataset or its split manifest is unavailable. Download data with a split manifest before training; evaluation requires the original cached dataset.",
dataPath);
}

byte[] bytes = File.ReadAllBytes(GetPath(dataPath));
var split = JsonSerializer.Deserialize<DatasetSplit>(bytes, JsonOptions)
?? throw new InvalidDataException("The dataset split manifest is empty.");
if (split.SchemaVersion != 1 || split.ColumnCount is not (3 or 5) || split.RowCount < 0 ||
split.TestRows is null || (split.RowCount > 0 && split.TestRows.Length >= split.RowCount) ||
split.TestRows.Where((row, index) => row < 0 || row >= split.RowCount ||
(index > 0 && row <= split.TestRows[index - 1])).Any())
{
throw new InvalidDataException("The dataset split manifest has invalid schema or row membership.");
}

if (!HashFile(dataPath).Equals(split.DatasetSha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("The dataset does not match its split manifest. Restore the original dataset; do not regenerate the split for evaluation.");
}

if (ReadHeader(dataPath).Length != split.ColumnCount)
{
throw new InvalidDataException("The dataset columns do not match its split manifest.");
}

split.fingerprint = Convert.ToHexStringLower(SHA256.HashData(bytes));
return split;
}

public IEnumerable<DatasetRow> ReadRows(string dataPath, bool test)
{
var testRows = new HashSet<int>(TestRows);
int index = 0;
foreach (string[] fields in ReadRecords(dataPath))
{
if (testRows.Contains(index++) == test)
{
DatasetRow row = ColumnCount == 3 ? new DatasetRow() : new PullRequestDatasetRow
{
FileNames = fields[3],
FolderNames = fields[4]
};
row.Label = fields[0];
row.Title = fields[1];
row.Body = fields[2];
yield return row;
}
}

if (index != RowCount)
{
throw new InvalidDataException("The dataset row count does not match its split manifest.");
}
}

public void WriteModelReference(string modelPath)
{
using var archive = ZipFile.Open(modelPath, ZipArchiveMode.Update);
archive.GetEntry(ModelReferenceEntry)?.Delete();
using var writer = new StreamWriter(archive.CreateEntry(ModelReferenceEntry).Open(), Encoding.ASCII);
writer.Write(fingerprint);
}

public void VerifyModelReference(string modelPath)
{
using var archive = ZipFile.OpenRead(modelPath);
var entry = archive.GetEntry(ModelReferenceEntry)
?? throw new InvalidDataException("The model has no dataset split reference. Retrain it using a dataset with a split manifest.");
using var reader = new StreamReader(entry.Open());
if (!fingerprint.Equals(reader.ReadToEnd(), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("The model was trained with a different dataset or split. Restore the original dataset and manifest.");
}
}

private static string HashFile(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}

private static string[] ReadHeader(string path)
{
string[] fields = File.ReadLines(path).FirstOrDefault()?.Split('\t')
?? throw new InvalidDataException("The dataset has no header.");
string[] expected = fields.Length == 3
? ["Label", "Title", "Body"]
: ["Label", "Title", "Body", "FileNames", "FolderNames"];
if (!fields.SequenceEqual(expected))
{
throw new InvalidDataException("The dataset has an invalid column header.");
}

return fields;
}

private static IEnumerable<string[]> ReadRecords(string path)
{
int columnCount = ReadHeader(path).Length;
foreach (string line in File.ReadLines(path).Skip(1))
{
string[] fields = line.Split('\t');
if (fields.Length != columnCount)
{
throw new InvalidDataException("A dataset row has an invalid column count.");
}

// Match TextLoader's whole-line trailing whitespace trim, not a trim of every field.
for (int last = fields.Length - 1; last >= 0; last--)
{
fields[last] = fields[last].TrimEnd();
if (fields[last].Length > 0)
{
break;
}
}

yield return [.. fields.Select(field => field.TrimStart(' '))];
}
}
}

public class DatasetRow
{
public string Label { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}

public sealed class PullRequestDatasetRow : DatasetRow
{
public string FileNames { get; set; } = string.Empty;
public string FolderNames { get; set; } = string.Empty;
}
2 changes: 2 additions & 0 deletions IssueLabeler/src/Downloader/Args.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ static void ShowUsage(string? message, ICoreService action)
Required for downloading pull request data:
--pulls-data Path for pull request data file to create (TSV file).

Each dataset includes an adjacent .split.json manifest with a fixed 80/20 split.

Optional arguments:
--issues-limit Maximum number of issues to download. Defaults to: No limit.
--discussions-limit Maximum number of discussions to download. Defaults to: No limit.
Expand Down
6 changes: 6 additions & 0 deletions IssueLabeler/src/Downloader/Downloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
async Task DownloadIssues(string outputPath)
{
action.WriteInfo($"Issues Data Path: {outputPath}");
File.Delete(DatasetSplit.GetPath(outputPath));

byte perFlushCount = 0;

Expand All @@ -74,11 +75,13 @@ async Task DownloadIssues(string outputPath)
}

writer.Close();
DatasetSplit.Create(outputPath);
}

async Task DownloadPullRequests(string outputPath)
{
action.WriteInfo($"Pulls Data Path: {outputPath}");
File.Delete(DatasetSplit.GetPath(outputPath));

byte perFlushCount = 0;

Expand All @@ -102,11 +105,13 @@ async Task DownloadPullRequests(string outputPath)
}

writer.Close();
DatasetSplit.Create(outputPath);
}

async Task DownloadDiscussions(string outputPath)
{
action.WriteInfo($"Discussions Data Path: {outputPath}");
File.Delete(DatasetSplit.GetPath(outputPath));

byte perFlushCount = 0;

Expand All @@ -130,4 +135,5 @@ async Task DownloadDiscussions(string outputPath)
}

writer.Close();
DatasetSplit.Create(outputPath);
}
Loading
Loading