Docker Compose bot + Google Api
Build App / Build (push) Failing after 3s

This commit is contained in:
2026-08-06 16:23:50 +02:00
parent c9a3c252e1
commit 937b8554dd
44 changed files with 3360 additions and 74 deletions
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Google;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// Writes to Google Sheets as a service account.
///
/// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is
/// atomic — either every tab lands or none does — so a failure can never leave the ranking
/// half updated.
///
/// Setup, once: create a service account, download its JSON key, point
/// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the
/// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people
/// forget, so a 403 says so explicitly and names the address.
/// </summary>
public class GoogleApiSheetsWriter : ISheetsWriter
{
private readonly string _credentialsPath;
public GoogleApiSheetsWriter(string credentialsPath)
{
_credentialsPath = credentialsPath;
}
public string Name => "ServiceAccount";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath);
public async Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new SheetsExportException(503,
$"Service-account key not found at '{_credentialsPath}'. " +
"Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted.");
}
using var service = CreateService(out var accountEmail);
var tabs = request.Tabs ?? new List<SheetTable>();
try
{
// 1. What is already in the spreadsheet: titles, ids, and current grid sizes.
var get = service.Spreadsheets.Get(request.SpreadsheetId);
get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)";
var spreadsheet = await get.ExecuteAsync(ct);
var existing = (spreadsheet.Sheets ?? new List<Sheet>())
.Select(sheet => sheet.Properties)
.Where(properties => properties != null)
.ToList();
// 2. One atomic batch for the whole export.
var batch = BuildBatch(existing, tabs, out var results);
if (batch.Requests.Count > 0)
{
await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct);
}
return new SheetExportResult
{
SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId,
SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId),
Writer = Name,
Tabs = results,
Warnings = new List<string>()
};
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new SheetsExportException(502,
$"Google refused access to spreadsheet '{request.SpreadsheetId}'. " +
$"Share it as Editor with {accountEmail ?? "the service account address"}.", ex);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new SheetsExportException(502,
$"No spreadsheet with id '{request.SpreadsheetId}'. " +
"Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex);
}
catch (GoogleApiException ex)
{
throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex);
}
}
/// <summary>
/// Builds the credential from the key file explicitly. GoogleCredential.FromFile and
/// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client
/// initializer, so there is nothing for GoogleCredential to add here. Doing it this way
/// also hands us the account's own address for the "share the sheet with…" message.
/// </summary>
private SheetsService CreateService(out string accountEmail)
{
ServiceAccountCredential credential;
try
{
var json = File.ReadAllText(_credentialsPath);
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
.Deserialize<JsonCredentialParameters>(json);
if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType
|| string.IsNullOrEmpty(parameters.ClientEmail)
|| string.IsNullOrEmpty(parameters.PrivateKey))
{
throw new InvalidOperationException(
"not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)");
}
accountEmail = parameters.ClientEmail;
credential = new ServiceAccountCredential(
new ServiceAccountCredential.Initializer(parameters.ClientEmail)
{
ProjectId = parameters.ProjectId,
KeyId = parameters.PrivateKeyId,
Scopes = new[] { SheetsService.Scope.Spreadsheets }
}.FromPrivateKey(parameters.PrivateKey));
}
catch (Exception ex)
{
throw new SheetsExportException(503,
$"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex);
}
return new SheetsService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "LaDOSE"
});
}
#region Batch construction
/// <summary>
/// Turns "what the spreadsheet has" plus "what we want" into one ordered request list.
/// Deliberately static and free of I/O so the whole batch can be asserted in a test with
/// no network — the same split StatisticsService uses for its pure Aggregate.
///
/// Order matters: create missing tabs, resize, reindex, then per tab clear and write.
/// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts.
/// </summary>
public static BatchUpdateSpreadsheetRequest BuildBatch(
List<SheetProperties> existing,
List<SheetTable> tabs,
out List<SheetTabResult> results)
{
var requests = new List<Request>();
results = new List<SheetTabResult>();
var byTitle = (existing ?? new List<SheetProperties>())
.Where(properties => properties.Title != null)
.GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
// Ids for tabs that do not exist yet are only known after the batch runs, so the
// clear and write for those address the sheet by title through a placeholder id.
// AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves.
var nextId = NextFreeSheetId(byTitle.Values);
for (var index = 0; index < tabs.Count; index++)
{
var tab = tabs[index];
var values = ToRowData(tab);
var rowCount = values.Count;
var columnCount = tab.Header?.Count ?? 0;
byTitle.TryGetValue(tab.Name, out var properties);
var created = properties == null;
int sheetId;
if (created)
{
sheetId = nextId++;
requests.Add(new Request
{
AddSheet = new AddSheetRequest
{
Properties = new SheetProperties
{
SheetId = sheetId,
Title = tab.Name,
Index = index,
GridProperties = new GridProperties
{
RowCount = Math.Max(rowCount, 1),
ColumnCount = Math.Max(columnCount, 1),
FrozenRowCount = 1
}
}
}
});
}
else
{
sheetId = properties.SheetId ?? 0;
// Grow before writing: the API silently drops cells outside the grid, and a
// sheet created by hand defaults to 1000 x 26.
var haveRows = properties.GridProperties?.RowCount ?? 0;
var haveColumns = properties.GridProperties?.ColumnCount ?? 0;
if (haveRows < rowCount || haveColumns < columnCount)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "gridProperties.rowCount,gridProperties.columnCount",
Properties = new SheetProperties
{
SheetId = sheetId,
GridProperties = new GridProperties
{
RowCount = Math.Max(haveRows, rowCount),
ColumnCount = Math.Max(haveColumns, columnCount)
}
}
}
});
}
// Oldest event leftmost, so the tabs read in season order.
if (properties.Index != index)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "index",
Properties = new SheetProperties { SheetId = sheetId, Index = index }
}
});
}
}
// Clear the whole sheet first, then write. Fields = "userEnteredValue" is the
// equivalent of clearContents(): colours, notes and conditional formatting the
// user set up by hand all survive. Without the clear, cells beyond the new
// extent would linger from a previous, longer export.
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Range = new GridRange { SheetId = sheetId },
Fields = "userEnteredValue"
}
});
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 },
Rows = values,
Fields = "userEnteredValue"
}
});
results.Add(new SheetTabResult
{
RequestedName = tab.Name,
Name = tab.Name,
Rows = rowCount,
Columns = columnCount,
Created = created
});
}
return new BatchUpdateSpreadsheetRequest { Requests = requests };
}
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
{
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
return Math.Max(used + 1, 1);
}
/// <summary>
/// Header, then one row per player, then the footer after a blank line. Points and totals
/// go in as numbers rather than text so the user's formulas keep working.
/// </summary>
private static List<RowData> ToRowData(SheetTable tab)
{
var header = tab.Header ?? new List<string>();
var width = header.Count;
var rows = new List<RowData>
{
new RowData { Values = header.Select(Text).ToList() }
};
foreach (var row in tab.Rows ?? new List<SheetRow>())
{
var cells = new List<CellData> { Text(row.Player) };
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
cells.Add(Number(row.Total));
rows.Add(new RowData { Values = cells });
}
var footer = (tab.Footer ?? new List<string>()).Where(line => line != null).ToList();
if (footer.Count > 0)
{
rows.Add(new RowData { Values = Blank(width) });
foreach (var line in footer)
{
var cells = Blank(width);
if (cells.Count > 0) cells[0] = Text(line);
else cells.Add(Text(line));
rows.Add(new RowData { Values = cells });
}
}
return rows;
}
private static List<CellData> Blank(int width)
{
return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList();
}
private static CellData Text(string value)
{
return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } };
}
private static CellData Number(int value)
{
return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } };
}
#endregion
}
}