| 1 | using AgentForge.Plugin.Sdk; |
| 2 | using AgentForge.Abstractions; |
| 3 | using AgentForge.Contracts; |
| 4 | using AgentForge.Data; |
| 5 | using AgentForge.Models; |
| 6 | using AgentForge.Services; |
| 7 | |
| 8 | namespace AgentForge.Plugin.Issue.Endpoints; |
| 9 | |
| 10 | internal static class ForgeIssueEndpoints |
| 11 | { |
| 12 | internal static RouteGroupBuilder MapIssueEndpoints(this RouteGroupBuilder api) |
| 13 | { |
| 14 | api.MapGet($"/repos/{ForgeRepoRouting.Pattern}/issues", ListIssues); |
| 15 | api.MapPost($"/repos/{ForgeRepoRouting.Pattern}/issues", CreateIssue); |
| 16 | api.MapGet($"/repos/{ForgeRepoRouting.Pattern}/issues/{{number:int}}", GetIssue); |
| 17 | api.MapPatch($"/repos/{ForgeRepoRouting.Pattern}/issues/{{number:int}}", UpdateIssue); |
| 18 | api.MapPost($"/repos/{ForgeRepoRouting.Pattern}/issues/{{number:int}}/comments", AddIssueComment); |
| 19 | api.MapPost($"/repos/{ForgeRepoRouting.Pattern}/issues/{{number:int}}/anchors", LinkIssueAnchors); |
| 20 | return api; |
| 21 | } |
| 22 | |
| 23 | private static IResult ListIssues( |
| 24 | string name, |
| 25 | string? q, |
| 26 | HttpContext http, |
| 27 | ForgeRepository repository, |
| 28 | ForgeUrls urls) |
| 29 | { |
| 30 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 31 | var issues = string.IsNullOrWhiteSpace(q) |
| 32 | ? repository.ListIssues(repo.Id) |
| 33 | : repository.SearchIssues(repo.Id, q); |
| 34 | var responses = issues.Select(issue => ForgePluginResponses.ToIssueResponse(issue, repo.Name, urls)).ToList(); |
| 35 | return Results.Ok(new IssueListResponse(responses.Count, responses)); |
| 36 | } |
| 37 | |
| 38 | private static async Task<IResult> CreateIssue( |
| 39 | string name, |
| 40 | CreateIssueRequest request, |
| 41 | HttpContext context, |
| 42 | ForgeRepository repository, |
| 43 | ForgeUrls urls, |
| 44 | CiWebhookService ci, |
| 45 | CancellationToken cancellationToken) |
| 46 | { |
| 47 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, context); |
| 48 | var title = request.Title?.Trim() ?? string.Empty; |
| 49 | if (title.Length == 0) |
| 50 | throw new ForgeApiException(400, "Issue title is required."); |
| 51 | |
| 52 | var anchors = IssueAnchorResolver.Resolve(request.Anchors, request.AnchorBrackets); |
| 53 | var createdBy = ForgeActorResolver.ResolveActor(context, request.Author); |
| 54 | var issue = repository.InsertIssue( |
| 55 | repo.Id, |
| 56 | title, |
| 57 | request.Body?.Trim() ?? string.Empty, |
| 58 | anchors, |
| 59 | createdBy); |
| 60 | var response = ForgePluginResponses.ToIssueResponse(issue, repo.Name, urls); |
| 61 | await ci.EmitIopAsync( |
| 62 | ForgeIopEvent.ForIssue("issue.opened", repo.Name, issue.Number, response, createdBy, issue.Anchors), |
| 63 | cancellationToken).ConfigureAwait(false); |
| 64 | return Results.Created($"/api/v1/repos/{name}/issues/{issue.Number}", response); |
| 65 | } |
| 66 | |
| 67 | private static IResult GetIssue( |
| 68 | string name, |
| 69 | int number, |
| 70 | HttpContext http, |
| 71 | ForgeRepository repository, |
| 72 | ForgeUrls urls) |
| 73 | { |
| 74 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 75 | var issue = repository.GetIssueByNumber(repo.Id, number) |
| 76 | ?? throw new ForgeApiException(404, $"Issue #{number} not found."); |
| 77 | var comments = repository.ListIssueComments(issue.Id) |
| 78 | .Select(ForgePluginResponses.ToCommentResponse) |
| 79 | .ToList(); |
| 80 | return Results.Ok(ForgePluginResponses.ToIssueDetailResponse(issue, repo.Name, urls, comments)); |
| 81 | } |
| 82 | |
| 83 | private static IResult UpdateIssue( |
| 84 | string name, |
| 85 | int number, |
| 86 | UpdateIssueRequest request, |
| 87 | HttpContext http, |
| 88 | ForgeRepository repository, |
| 89 | ForgeUrls urls) |
| 90 | { |
| 91 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 92 | if (request.Title is null && request.Body is null && request.Status is null) |
| 93 | throw new ForgeApiException(400, "At least one of title, body, or status is required."); |
| 94 | |
| 95 | var issue = repository.UpdateIssue(repo.Id, number, request); |
| 96 | return Results.Ok(ForgePluginResponses.ToIssueResponse(issue, repo.Name, urls)); |
| 97 | } |
| 98 | |
| 99 | private static async Task<IResult> AddIssueComment( |
| 100 | string name, |
| 101 | int number, |
| 102 | AddIssueCommentRequest request, |
| 103 | HttpContext context, |
| 104 | ForgeRepository repository, |
| 105 | CiWebhookService ci, |
| 106 | CancellationToken cancellationToken) |
| 107 | { |
| 108 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, context); |
| 109 | var body = request.Body?.Trim() ?? string.Empty; |
| 110 | if (body.Length == 0) |
| 111 | throw new ForgeApiException(400, "Comment body is required."); |
| 112 | |
| 113 | var author = ForgeActorResolver.ResolveCommentAuthor(context, request.Author); |
| 114 | var comment = repository.InsertIssueComment(repo.Id, number, body, author); |
| 115 | var response = ForgePluginResponses.ToCommentResponse(comment); |
| 116 | await ci.EmitIopAsync( |
| 117 | ForgeIopEvent.ForIssue("issue.comment", repo.Name, number, response, author), |
| 118 | cancellationToken).ConfigureAwait(false); |
| 119 | return Results.Created( |
| 120 | $"/api/v1/repos/{name}/issues/{number}/comments/{comment.Id}", |
| 121 | response); |
| 122 | } |
| 123 | |
| 124 | private static IResult LinkIssueAnchors( |
| 125 | string name, |
| 126 | int number, |
| 127 | LinkIssueAnchorsRequest request, |
| 128 | HttpContext http, |
| 129 | ForgeRepository repository, |
| 130 | ForgeUrls urls) |
| 131 | { |
| 132 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 133 | var anchors = IssueAnchorResolver.Resolve(request.Anchors, request.AnchorBrackets); |
| 134 | if (anchors is null || anchors.Count == 0) |
| 135 | throw new ForgeApiException(400, "At least one anchor (anchors or anchorBrackets) is required."); |
| 136 | |
| 137 | var issue = repository.LinkIssueAnchors(repo.Id, number, anchors); |
| 138 | return Results.Ok(ForgePluginResponses.ToIssueResponse(issue, repo.Name, urls)); |
| 139 | } |
| 140 | } |
| 141 | |