Theme 3: download a product as a project (zip export)
New GET /api/orgboard/products/{id}/export streams a zip of the product's
delivered work: PRODUCT.md (identity), each team's artifacts written as real
source files when the artifact is a single fenced code block (App.tsx,
schema.sql, …) or markdown otherwise, plus a README manifest. Gated on
board-view permission. The Delivery dashboard gets a Download project button
that fetches the file with the auth header and saves it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -24,6 +28,7 @@ internal static class OrgBoardEndpoints
|
||||
group.MapGet("/products", ListProducts).RequireAuthorization();
|
||||
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
|
||||
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
|
||||
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
|
||||
group.MapPost("/teams", CreateTeam).RequireAuthorization();
|
||||
group.MapGet("/teams", ListTeams).RequireAuthorization();
|
||||
group.MapPost("/tasks", CreateTask).RequireAuthorization();
|
||||
@@ -230,6 +235,154 @@ internal static class OrgBoardEndpoints
|
||||
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
|
||||
}
|
||||
|
||||
// Matches a fenced code block, capturing its language hint and body, so we can write a delivered
|
||||
// artifact out as a real source file (App.tsx, schema.sql, …) instead of a wall of markdown.
|
||||
private static readonly Regex FenceRx = new(
|
||||
@"```(?<lang>[a-zA-Z0-9+#.]*)\s*\n(?<body>.*?)```",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
|
||||
// Download the product as a project: PRODUCT.md + every team's delivered artifacts as files,
|
||||
// plus a README manifest. This is the "an agent did the work, now I download the result" payoff —
|
||||
// a portable bundle of what the team (human + AI) produced, gated on board-view permission.
|
||||
private static async Task<IResult> ExportProduct(
|
||||
Guid id, IPermissionService permissions, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (product is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var teams = await db.Teams
|
||||
.Where(t => t.ProductId == id)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
var teamIds = teams.Select(t => t.Id).ToList();
|
||||
var teamsById = teams.ToDictionary(t => t.Id);
|
||||
|
||||
// Only items that actually carry a delivered artifact are worth exporting.
|
||||
var items = await db.WorkItems
|
||||
.Where(w => teamIds.Contains(w.TeamId) && w.Description != null && w.Description != "")
|
||||
.OrderBy(w => w.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var manifest = new StringBuilder();
|
||||
manifest.Append("# ").Append(product.Name).Append("\n\n");
|
||||
manifest.Append("_Exported from TeamUp on ")
|
||||
.Append(clock.GetUtcNow().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)).Append("._\n\n");
|
||||
manifest.Append(items.Count).Append(" delivered artifact(s) across ")
|
||||
.Append(teams.Count).Append(" team(s).\n\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Identity))
|
||||
{
|
||||
WriteEntry(zip, "PRODUCT.md", product.Identity!);
|
||||
manifest.Append("- `PRODUCT.md` — product identity\n");
|
||||
}
|
||||
|
||||
var counters = new Dictionary<Guid, int>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var team = teamsById[item.TeamId];
|
||||
var folder = Slug(team.Name);
|
||||
var n = counters.TryGetValue(item.TeamId, out var c) ? c + 1 : 1;
|
||||
counters[item.TeamId] = n;
|
||||
|
||||
var (ext, content) = RenderArtifact(item.Description!);
|
||||
var path = $"{folder}/{n:D2}-{Slug(item.Title)}{ext}";
|
||||
WriteEntry(zip, path, content);
|
||||
manifest.Append("- `").Append(path).Append("` — ").Append(item.Title).Append('\n');
|
||||
}
|
||||
|
||||
WriteEntry(zip, "README.md", manifest.ToString());
|
||||
}
|
||||
|
||||
return Results.File(buffer.ToArray(), "application/zip", $"{Slug(product.Name)}.zip");
|
||||
}
|
||||
|
||||
private static void WriteEntry(ZipArchive zip, string path, string content)
|
||||
{
|
||||
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
// If a delivered artifact is essentially one fenced code block, write it out as that source file;
|
||||
// otherwise keep it as markdown.
|
||||
private static (string Ext, string Content) RenderArtifact(string artifact)
|
||||
{
|
||||
var matches = FenceRx.Matches(artifact);
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
var largest = matches
|
||||
.OrderByDescending(m => m.Groups["body"].Value.Length)
|
||||
.First();
|
||||
var body = largest.Groups["body"].Value;
|
||||
if (body.Length >= artifact.Trim().Length * 0.5)
|
||||
{
|
||||
return (ExtensionFor(largest.Groups["lang"].Value), body.TrimEnd() + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
return (".md", artifact);
|
||||
}
|
||||
|
||||
private static string ExtensionFor(string lang) => lang.ToLowerInvariant() switch
|
||||
{
|
||||
"tsx" => ".tsx",
|
||||
"ts" or "typescript" => ".ts",
|
||||
"jsx" => ".jsx",
|
||||
"js" or "javascript" => ".js",
|
||||
"cs" or "csharp" => ".cs",
|
||||
"py" or "python" => ".py",
|
||||
"go" or "golang" => ".go",
|
||||
"java" => ".java",
|
||||
"rb" or "ruby" => ".rb",
|
||||
"php" => ".php",
|
||||
"rs" or "rust" => ".rs",
|
||||
"sql" => ".sql",
|
||||
"html" => ".html",
|
||||
"css" => ".css",
|
||||
"scss" => ".scss",
|
||||
"json" => ".json",
|
||||
"yaml" or "yml" => ".yml",
|
||||
"sh" or "bash" or "shell" => ".sh",
|
||||
"md" or "markdown" => ".md",
|
||||
_ => ".txt",
|
||||
};
|
||||
|
||||
// Filesystem-safe lower-kebab slug for folder/file names.
|
||||
private static string Slug(string value)
|
||||
{
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var ch in value.Trim().ToLowerInvariant())
|
||||
{
|
||||
sb.Append(char.IsLetterOrDigit(ch) ? ch : '-');
|
||||
}
|
||||
|
||||
var slug = sb.ToString();
|
||||
while (slug.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
slug = slug.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
slug = slug.Trim('-');
|
||||
if (slug.Length == 0)
|
||||
{
|
||||
return "item";
|
||||
}
|
||||
|
||||
return slug.Length > 50 ? slug[..50].Trim('-') : slug;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTeams(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user