diff --git a/sdk/csharp/Gitea.SDK/ChunkedUpload.cs b/sdk/csharp/Gitea.SDK/ChunkedUpload.cs
new file mode 100644
index 0000000000..8e4bb788b1
--- /dev/null
+++ b/sdk/csharp/Gitea.SDK/ChunkedUpload.cs
@@ -0,0 +1,172 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+using System.Diagnostics;
+using System.Security.Cryptography;
+
+namespace Gitea.SDK;
+
+///
+/// Handles chunked file uploads with parallel workers.
+///
+public class ChunkedUpload
+{
+ private readonly GiteaClient _client;
+ private readonly string _owner;
+ private readonly string _repo;
+ private readonly long _releaseId;
+ private readonly ChunkedUploadOptions _options;
+ private UploadSession? _session;
+
+ internal ChunkedUpload(
+ GiteaClient client,
+ string owner,
+ string repo,
+ long releaseId,
+ ChunkedUploadOptions options)
+ {
+ _client = client;
+ _owner = owner;
+ _repo = repo;
+ _releaseId = releaseId;
+ _options = options;
+ }
+
+ ///
+ /// Gets the current upload session.
+ ///
+ public UploadSession? Session => _session;
+
+ ///
+ /// Uploads a file using chunked upload.
+ ///
+ public async Task UploadAsync(
+ Stream fileStream,
+ string filename,
+ CancellationToken cancellationToken = default)
+ {
+ var fileSize = fileStream.Length;
+ var chunkSize = _options.ChunkSize;
+ var totalChunks = (int)Math.Ceiling((double)fileSize / chunkSize);
+
+ // Calculate checksum if requested
+ string? checksum = null;
+ if (_options.VerifyChecksum)
+ {
+ checksum = await ComputeChecksumAsync(fileStream, cancellationToken);
+ fileStream.Position = 0;
+ }
+
+ // Create upload session
+ _session = await CreateSessionAsync(filename, fileSize, chunkSize, totalChunks, checksum, cancellationToken);
+
+ // Track progress
+ var stopwatch = Stopwatch.StartNew();
+ var chunksCompleted = 0;
+ var bytesCompleted = 0L;
+ var progressLock = new object();
+
+ // Upload chunks in parallel
+ var semaphore = new SemaphoreSlim(_options.Parallel);
+ var tasks = new List();
+ var chunks = new List<(int Index, byte[] Data)>();
+
+ // Read all chunks
+ for (var i = 0; i < totalChunks; i++)
+ {
+ var offset = i * chunkSize;
+ var size = (int)Math.Min(chunkSize, fileSize - offset);
+ var buffer = new byte[size];
+ fileStream.Position = offset;
+ await fileStream.ReadAsync(buffer.AsMemory(0, size), cancellationToken);
+ chunks.Add((i, buffer));
+ }
+
+ // Upload chunks
+ foreach (var (index, data) in chunks)
+ {
+ await semaphore.WaitAsync(cancellationToken);
+
+ var task = Task.Run(async () =>
+ {
+ try
+ {
+ await UploadChunkAsync(_session.Id, index, data, cancellationToken);
+
+ lock (progressLock)
+ {
+ chunksCompleted++;
+ bytesCompleted += data.Length;
+
+ var elapsed = stopwatch.Elapsed.TotalSeconds;
+ var speed = elapsed > 0 ? bytesCompleted / elapsed : 0;
+ var remaining = fileSize - bytesCompleted;
+ var eta = speed > 0 ? TimeSpan.FromSeconds(remaining / speed) : TimeSpan.Zero;
+
+ _options.OnProgress?.Invoke(new Progress
+ {
+ BytesDone = bytesCompleted,
+ BytesTotal = fileSize,
+ ChunksDone = chunksCompleted,
+ ChunksTotal = totalChunks,
+ Percent = (double)bytesCompleted / fileSize * 100,
+ Speed = speed,
+ Eta = eta
+ });
+ }
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }, cancellationToken);
+
+ tasks.Add(task);
+ }
+
+ await Task.WhenAll(tasks);
+
+ // Complete the upload
+ return await CompleteUploadAsync(_session.Id, cancellationToken);
+ }
+
+ private async Task ComputeChecksumAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ using var sha256 = SHA256.Create();
+ var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ private async Task CreateSessionAsync(
+ string filename,
+ long fileSize,
+ long chunkSize,
+ int totalChunks,
+ string? checksum,
+ CancellationToken cancellationToken)
+ {
+ var path = $"/api/v1/repos/{Uri.EscapeDataString(_owner)}/{Uri.EscapeDataString(_repo)}/releases/{_releaseId}/assets/upload";
+ var body = new
+ {
+ filename,
+ file_size = fileSize,
+ chunk_size = chunkSize,
+ total_chunks = totalChunks,
+ checksum
+ };
+
+ return await _client.PostAsync(path, body, cancellationToken);
+ }
+
+ private async Task UploadChunkAsync(string sessionId, int chunkIndex, byte[] data, CancellationToken cancellationToken)
+ {
+ var path = $"/api/v1/repos/uploads/{Uri.EscapeDataString(sessionId)}/chunks/{chunkIndex}";
+ await _client.PutBinaryAsync(path, data, cancellationToken);
+ }
+
+ private async Task CompleteUploadAsync(string sessionId, CancellationToken cancellationToken)
+ {
+ var path = $"/api/v1/repos/uploads/{Uri.EscapeDataString(sessionId)}/complete";
+ return await _client.PostAsync(path, null, cancellationToken);
+ }
+}
diff --git a/sdk/csharp/Gitea.SDK/Exceptions.cs b/sdk/csharp/Gitea.SDK/Exceptions.cs
new file mode 100644
index 0000000000..94cec4223d
--- /dev/null
+++ b/sdk/csharp/Gitea.SDK/Exceptions.cs
@@ -0,0 +1,84 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+namespace Gitea.SDK;
+
+///
+/// Base exception for all Gitea SDK errors.
+///
+public class GiteaException : Exception
+{
+ public GiteaException(string message) : base(message) { }
+ public GiteaException(string message, Exception inner) : base(message, inner) { }
+}
+
+///
+/// Thrown when authentication fails (401).
+///
+public class AuthenticationException : GiteaException
+{
+ public AuthenticationException(string message) : base(message) { }
+}
+
+///
+/// Thrown when a resource is not found (404).
+///
+public class NotFoundException : GiteaException
+{
+ public NotFoundException(string message) : base(message) { }
+}
+
+///
+/// Thrown when the API returns an error response.
+///
+public class ApiException : GiteaException
+{
+ public string? Code { get; }
+ public int StatusCode { get; }
+
+ public ApiException(string message, string? code, int statusCode)
+ : base(message)
+ {
+ Code = code;
+ StatusCode = statusCode;
+ }
+}
+
+///
+/// Thrown when an upload operation fails.
+///
+public class UploadException : GiteaException
+{
+ public string? SessionId { get; }
+ public int? ChunkNumber { get; }
+
+ public UploadException(string message, string? sessionId = null, int? chunkNumber = null)
+ : base(message)
+ {
+ SessionId = sessionId;
+ ChunkNumber = chunkNumber;
+ }
+
+ public UploadException(string message, Exception inner, string? sessionId = null, int? chunkNumber = null)
+ : base(message, inner)
+ {
+ SessionId = sessionId;
+ ChunkNumber = chunkNumber;
+ }
+}
+
+///
+/// Thrown when checksum verification fails.
+///
+public class ChecksumException : UploadException
+{
+ public string? Expected { get; }
+ public string? Actual { get; }
+
+ public ChecksumException(string message, string? expected = null, string? actual = null, string? sessionId = null)
+ : base(message, sessionId)
+ {
+ Expected = expected;
+ Actual = actual;
+ }
+}
diff --git a/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj b/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj
new file mode 100644
index 0000000000..de3d98adae
--- /dev/null
+++ b/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ latest
+
+
+ Gitea.SDK
+ 1.0.0
+ The Gitea Authors
+ Gitea
+ Official .NET SDK for the Gitea API with chunked upload support for large files
+ gitea;git;api;sdk;client;upload;chunked
+ MIT
+ https://docs.gitea.io/sdk/csharp
+ https://github.com/go-gitea/gitea.git
+ git
+
+
+ true
+ $(NoWarn);CS1591
+
+
+
diff --git a/sdk/csharp/Gitea.SDK/GiteaClient.cs b/sdk/csharp/Gitea.SDK/GiteaClient.cs
new file mode 100644
index 0000000000..98d0bc2de4
--- /dev/null
+++ b/sdk/csharp/Gitea.SDK/GiteaClient.cs
@@ -0,0 +1,268 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Gitea.SDK;
+
+///
+/// Client for the Gitea API.
+///
+public class GiteaClient : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _baseUrl;
+ private readonly JsonSerializerOptions _jsonOptions;
+ private bool _disposed;
+
+ ///
+ /// Creates a new Gitea API client.
+ ///
+ /// Base URL of the Gitea instance
+ /// API token for authentication
+ /// Optional HttpClient instance
+ public GiteaClient(string baseUrl, string? token = null, HttpClient? httpClient = null)
+ {
+ _baseUrl = baseUrl.TrimEnd('/');
+ _httpClient = httpClient ?? new HttpClient();
+ _httpClient.DefaultRequestHeaders.UserAgent.Add(
+ new ProductInfoHeaderValue("Gitea.SDK", "1.0.0"));
+
+ if (!string.IsNullOrEmpty(token))
+ {
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("token", token);
+ }
+
+ _jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+ }
+
+ #region User Methods
+
+ ///
+ /// Gets the currently authenticated user.
+ ///
+ public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default)
+ {
+ return await GetAsync("/api/v1/user", cancellationToken);
+ }
+
+ ///
+ /// Gets a user by username.
+ ///
+ public async Task GetUserAsync(string username, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync($"/api/v1/users/{Uri.EscapeDataString(username)}", cancellationToken);
+ }
+
+ #endregion
+
+ #region Repository Methods
+
+ ///
+ /// Gets a repository by owner and name.
+ ///
+ public async Task GetRepositoryAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync(
+ $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}",
+ cancellationToken);
+ }
+
+ ///
+ /// Lists repositories for a user.
+ ///
+ public async Task> ListUserRepositoriesAsync(string username, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync>(
+ $"/api/v1/users/{Uri.EscapeDataString(username)}/repos",
+ cancellationToken);
+ }
+
+ #endregion
+
+ #region Release Methods
+
+ ///
+ /// Gets a release by ID.
+ ///
+ public async Task GetReleaseAsync(string owner, string repo, long releaseId, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync(
+ $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases/{releaseId}",
+ cancellationToken);
+ }
+
+ ///
+ /// Gets a release by tag name.
+ ///
+ public async Task GetReleaseByTagAsync(string owner, string repo, string tag, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync(
+ $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases/tags/{Uri.EscapeDataString(tag)}",
+ cancellationToken);
+ }
+
+ ///
+ /// Lists all releases for a repository.
+ ///
+ public async Task> ListReleasesAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync>(
+ $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases",
+ cancellationToken);
+ }
+
+ #endregion
+
+ #region Upload Methods
+
+ ///
+ /// Uploads a release asset using chunked upload.
+ ///
+ public async Task UploadReleaseAssetAsync(
+ string owner,
+ string repo,
+ long releaseId,
+ Stream fileStream,
+ string filename,
+ ChunkedUploadOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var upload = new ChunkedUpload(this, owner, repo, releaseId, options ?? new ChunkedUploadOptions());
+ return await upload.UploadAsync(fileStream, filename, cancellationToken);
+ }
+
+ ///
+ /// Creates a chunked upload handler.
+ ///
+ public ChunkedUpload CreateChunkedUpload(
+ string owner,
+ string repo,
+ long releaseId,
+ ChunkedUploadOptions? options = null)
+ {
+ return new ChunkedUpload(this, owner, repo, releaseId, options ?? new ChunkedUploadOptions());
+ }
+
+ ///
+ /// Gets the status of an upload session.
+ ///
+ public async Task GetUploadSessionAsync(string sessionId, CancellationToken cancellationToken = default)
+ {
+ return await GetAsync($"/api/v1/repos/uploads/{sessionId}", cancellationToken);
+ }
+
+ ///
+ /// Cancels an upload session.
+ ///
+ public async Task CancelUploadAsync(string sessionId, CancellationToken cancellationToken = default)
+ {
+ await DeleteAsync($"/api/v1/repos/uploads/{sessionId}", cancellationToken);
+ }
+
+ #endregion
+
+ #region Internal HTTP Methods
+
+ internal async Task GetAsync(string path, CancellationToken cancellationToken)
+ {
+ var response = await _httpClient.GetAsync(_baseUrl + path, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken)
+ ?? throw new GiteaException("Empty response");
+ }
+
+ internal async Task PostAsync(string path, object? body, CancellationToken cancellationToken)
+ {
+ var response = await _httpClient.PostAsJsonAsync(_baseUrl + path, body, _jsonOptions, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken)
+ ?? throw new GiteaException("Empty response");
+ }
+
+ internal async Task PostAsync(string path, object? body, CancellationToken cancellationToken)
+ {
+ var response = await _httpClient.PostAsJsonAsync(_baseUrl + path, body, _jsonOptions, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ }
+
+ internal async Task PutBinaryAsync(string path, byte[] data, CancellationToken cancellationToken)
+ {
+ var content = new ByteArrayContent(data);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+ var response = await _httpClient.PutAsync(_baseUrl + path, content, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken)
+ ?? throw new GiteaException("Empty response");
+ }
+
+ internal async Task PutBinaryAsync(string path, byte[] data, CancellationToken cancellationToken)
+ {
+ var content = new ByteArrayContent(data);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+ var response = await _httpClient.PutAsync(_baseUrl + path, content, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ }
+
+ internal async Task DeleteAsync(string path, CancellationToken cancellationToken)
+ {
+ var response = await _httpClient.DeleteAsync(_baseUrl + path, cancellationToken);
+ await EnsureSuccessAsync(response, cancellationToken);
+ }
+
+ private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ if (response.IsSuccessStatusCode)
+ return;
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ switch ((int)response.StatusCode)
+ {
+ case 401:
+ throw new AuthenticationException("Authentication failed");
+ case 404:
+ throw new NotFoundException("Resource not found");
+ default:
+ try
+ {
+ var error = JsonSerializer.Deserialize(content, _jsonOptions);
+ throw new ApiException(
+ error?.Message ?? content,
+ error?.Code,
+ (int)response.StatusCode);
+ }
+ catch (JsonException)
+ {
+ throw new ApiException(content, null, (int)response.StatusCode);
+ }
+ }
+ }
+
+ #endregion
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _httpClient.Dispose();
+ _disposed = true;
+ }
+ GC.SuppressFinalize(this);
+ }
+}
+
+internal class ApiErrorResponse
+{
+ public string? Code { get; set; }
+ public string? Message { get; set; }
+ public int? Status { get; set; }
+}
diff --git a/sdk/csharp/Gitea.SDK/Models.cs b/sdk/csharp/Gitea.SDK/Models.cs
new file mode 100644
index 0000000000..7eeb820882
--- /dev/null
+++ b/sdk/csharp/Gitea.SDK/Models.cs
@@ -0,0 +1,161 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+using System.Text.Json.Serialization;
+
+namespace Gitea.SDK;
+
+///
+/// Represents a Gitea user.
+///
+public record User
+{
+ public long Id { get; init; }
+ public string Login { get; init; } = string.Empty;
+ public string FullName { get; init; } = string.Empty;
+ public string Email { get; init; } = string.Empty;
+ public string AvatarUrl { get; init; } = string.Empty;
+ public bool IsAdmin { get; init; }
+}
+
+///
+/// Represents a Gitea repository.
+///
+public record Repository
+{
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string FullName { get; init; } = string.Empty;
+ public User? Owner { get; init; }
+ public string Description { get; init; } = string.Empty;
+ public bool Private { get; init; }
+ public bool Fork { get; init; }
+ public string DefaultBranch { get; init; } = "main";
+ public int StarsCount { get; init; }
+ public int ForksCount { get; init; }
+ public string CloneUrl { get; init; } = string.Empty;
+ public string HtmlUrl { get; init; } = string.Empty;
+}
+
+///
+/// Represents a release attachment/asset.
+///
+public record Attachment
+{
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public long Size { get; init; }
+ public long DownloadCount { get; init; }
+ public string BrowserDownloadUrl { get; init; } = string.Empty;
+ public DateTime CreatedAt { get; init; }
+}
+
+///
+/// Represents a Gitea release.
+///
+public record Release
+{
+ public long Id { get; init; }
+ public string TagName { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public string Body { get; init; } = string.Empty;
+ public bool Draft { get; init; }
+ public bool Prerelease { get; init; }
+ public DateTime PublishedAt { get; init; }
+ public List Assets { get; init; } = new();
+}
+
+///
+/// Represents a chunked upload session.
+///
+public record UploadSession
+{
+ public string Id { get; init; } = string.Empty;
+ public string FileName { get; init; } = string.Empty;
+ public long FileSize { get; init; }
+ public long ChunkSize { get; init; }
+ public long TotalChunks { get; init; }
+ public long ChunksReceived { get; init; }
+ public string Status { get; init; } = "pending";
+ public DateTime ExpiresAt { get; init; }
+ public string? Checksum { get; init; }
+}
+
+///
+/// Represents the result of a completed upload.
+///
+public record UploadResult
+{
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public long Size { get; init; }
+ public string BrowserDownloadUrl { get; init; } = string.Empty;
+ public bool ChecksumVerified { get; init; }
+}
+
+///
+/// Represents upload progress.
+///
+public record Progress
+{
+ /// Bytes uploaded so far.
+ public long BytesDone { get; init; }
+
+ /// Total bytes to upload.
+ public long BytesTotal { get; init; }
+
+ /// Number of chunks uploaded.
+ public long ChunksDone { get; init; }
+
+ /// Total number of chunks.
+ public long ChunksTotal { get; init; }
+
+ /// Percentage complete (0-100).
+ public double Percent { get; init; }
+
+ /// Upload speed in bytes per second.
+ public double Speed { get; init; }
+
+ /// Estimated time remaining.
+ public TimeSpan Eta { get; init; }
+
+ ///
+ /// Formats the speed as a human-readable string.
+ ///
+ public string SpeedFormatted => FormatBytes((long)Speed) + "/s";
+
+ ///
+ /// Formats the ETA as a human-readable string.
+ ///
+ public string EtaFormatted => Eta.TotalSeconds < 60
+ ? $"{(int)Eta.TotalSeconds}s"
+ : Eta.TotalMinutes < 60
+ ? $"{(int)Eta.TotalMinutes}m{Eta.Seconds}s"
+ : $"{(int)Eta.TotalHours}h{Eta.Minutes}m";
+
+ private static string FormatBytes(long bytes)
+ {
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
+ if (bytes < 1024 * 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB";
+ return $"{bytes / 1024.0 / 1024.0 / 1024.0:F1} GB";
+ }
+}
+
+///
+/// Options for chunked uploads.
+///
+public class ChunkedUploadOptions
+{
+ /// Size of each chunk in bytes (default: 10MB).
+ public long ChunkSize { get; set; } = 10 * 1024 * 1024;
+
+ /// Number of parallel upload workers (default: 4).
+ public int Parallel { get; set; } = 4;
+
+ /// Whether to verify file checksum (default: true).
+ public bool VerifyChecksum { get; set; } = true;
+
+ /// Progress callback.
+ public Action