diff --git a/CHANGELOG.md b/CHANGELOG.md index 1087ece..c79c593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.7.2 — 2026-10-16 + +- `ado_pr merged=` (`adolens pr --merged staging --since 7d`): pull requests completed into a branch, newest + first, each with its work items, their type, state and tags - "what went into UAT this week"; `since` takes a time + too ("2026-10-15 14:30"). +- `ado_pipeline deployments=true` (`adolens pipeline --deployments --definition --stage UAT`): recent release + deployments with date and time, release and build - the moment to use as `since` ("PRs to staging after deployment + XYZ"). +- Dates given as `since=yyyy-MM-dd` are local days (before, a day earlier in Polish time was shown and used). + ## 0.7.1 — 2026-10-15 Fixes from use in a process with many required fields. diff --git a/README.md b/README.md index cd70bd6..1d2debe 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_backlog` | a team's backlog in priority order (requirement, portfolio, task, or by name); `move` + `above`/`below` reorders | refinement, priorities | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | | `ado_trace` | was it done as asked, and where is it: the acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and pull requests linked to the item and its tasks with the X++ objects they touch; builds (also those deleted by retention); every release stage of the code's branches with **YES / NOT YET**, labelled by the environment map (`staging` → UAT); for Git, whether the change is merged into each mapped branch; tests, tasks and warning signals | "did they do what I asked", "is it on UAT yet" | `adolens trace 123` | | `ado_changes` | the files of a changeset (`523`), pull request (`PR 482`) or commit (sha + `repo`) with X++ objects and their added / changed / removed methods; `file` gives the diff of one file — for AOT XML per method plus the metadata (fields, controls, properties) without raw XML noise | reading what exactly changed | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | -| `ado_pr` | without id: pull requests waiting for my review and mine (`list=active`: all); with id: status, reviewers and votes, checks (build, work item linking, reviewers), linked work items, open comment threads written by people (not system notes) | code review, "what is blocking my PR" | `adolens pr`, `adolens pr 482` | +| `ado_pr` | without id: pull requests waiting for my review and mine (`list=active`: all); `merged=`: those completed into a branch (`since`, `top`, `repo`) with their work items; with id: status, reviewers and votes, checks (build, work item linking, reviewers), linked work items, open comment threads written by people (not system notes) | code review, "what is blocking my PR" | `adolens pr`, `adolens pr 482` | | `ado_pr_update` | `create` (repo, source, target, title, markdown text, work items, reviewers, draft), `comment` (general or on a file and line), `reply`, `resolve`, `vote`, `publish`, `complete`, `abandon`; `@[name]` notifies | reviewing, opening and finishing PRs | — | | `ado_pipeline` | recent builds (of a pipeline, a branch); with `build`: the failed stage, job and steps, their errors, and the lines of the log that report errors (MSBuild noise cut); `artifacts=true` lists the build's artifacts, `artifact=` the files in one (`part=` reads a file), `save=true` saves it to disk | "why is the build red", getting a package or a log | `adolens pipeline 900`, `adolens pipeline 900 --artifact drop --save` | | `ado_pipeline_run` | queues a pipeline by name or id, optionally on a branch, with variables | re-running a build | — | diff --git a/README.pl.md b/README.pl.md index 9543e7a..33afb9f 100644 --- a/README.pl.md +++ b/README.pl.md @@ -209,7 +209,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_backlog` | backlog zespołu w kolejności priorytetu (wymagania, portfolio, zadania albo po nazwie); `move` + `above`/`below` zmienia kolejność | refinement, priorytety | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | | `ado_trace` | czy zrobiono to, o co proszono, i gdzie to jest: kryteria akceptacji (dla zadania — z elementu nadrzędnego) i to, czy zmieniły się po rozpoczęciu prac; changesety, commity i pull requesty podpięte pod element i jego zadania, z obiektami X++, których dotyczą; buildy (także usunięte przez retencję); każdy etap release'u gałęzi tego kodu z **TAK / JESZCZE NIE** i etykietą z mapy środowisk (`staging` → UAT); dla Gita — czy zmiana jest scalona do każdej zmapowanej gałęzi; testy, zadania i sygnały ostrzegawcze | „czy zrobili to, o co prosiłem”, „czy to już jest na UAT” | `adolens trace 123` | | `ado_changes` | pliki changesetu (`523`), pull requesta (`PR 482`) albo commita (sha + `repo`) z obiektami X++ i ich dodanymi / zmienionymi / usuniętymi metodami; `file` daje diff jednego pliku — dla AOT XML per metoda plus metadane (pola, kontrolki, właściwości) bez szumu surowego XML-a | czytanie, co dokładnie się zmieniło | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | -| `ado_pr` | bez id: pull requesty czekające na mój przegląd i moje (`list=active`: wszystkie); z id: stan, recenzenci i głosy, kontrole (build, podpięty work item, recenzenci), podpięte work itemy, otwarte wątki pisane przez ludzi (bez wpisów systemowych) | code review, „co blokuje mój PR” | `adolens pr`, `adolens pr 482` | +| `ado_pr` | `merged=`: pull requesty scalone do gałęzi (`since`, `top`, `repo`) z ich work itemami; bez id: pull requesty czekające na mój przegląd i moje (`list=active`: wszystkie); z id: stan, recenzenci i głosy, kontrole (build, podpięty work item, recenzenci), podpięte work itemy, otwarte wątki pisane przez ludzi (bez wpisów systemowych) | code review, „co blokuje mój PR” | `adolens pr`, `adolens pr 482` | | `ado_pr_update` | `create` (repo, źródło, cel, tytuł, opis w markdownie, work itemy, recenzenci, szkic), `comment` (ogólny albo na pliku i linii), `reply`, `resolve`, `vote`, `publish`, `complete`, `abandon`; `@[imię]` wysyła powiadomienie | przegląd, zakładanie i kończenie PR-ów | — | | `ado_pipeline` | ostatnie buildy (pipeline'u, gałęzi); z `build`: nieudany etap, zadanie i kroki, ich błędy i linie logu zgłaszające błędy (bez szumu MSBuild); `artifacts=true` lista artefaktów buildu, `artifact=` pliki w nim (`part=` czyta plik), `save=true` zapisuje go na dysk | „czemu build jest czerwony”, pobranie paczki lub logu | `adolens pipeline 900`, `adolens pipeline 900 --artifact drop --save` | | `ado_pipeline_run` | uruchamia pipeline po nazwie albo id, opcjonalnie na gałęzi, ze zmiennymi | ponowne puszczenie buildu | — | diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index d9db749..97a0339 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.7.1 + 0.7.2 diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index c8246f8..22d041f 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -53,8 +53,10 @@ board [] --team --move --column --detail summary|norma trace was it done as asked, and where is it (DEV / UAT / PROD) --detail summary|full changes <523 | C523 | "PR 482" | sha --repo > files, X++ methods; --file for a diff pr [] PRs waiting for my review and mine (--list active: all); with id: reviewers, checks, threads + --merged [--since 7d] [--repo ] [--top 30]: completed into a branch, with work items pipeline [] recent builds (--definition --branch --top 10); with id: why it failed --artifacts (list) | --artifact [--part ] [--save [--to ]] + --deployments [--definition ] [--stage ]: release deployments with dates wiki [] --search --depth 2 --detail full --wiki wiki-write --file (or standard input) --mode create|append|prepend|replace --version (for replace, from 'wiki ') --comment @@ -843,7 +845,9 @@ public async Task PrAsync() var lens = NewLens(); Print(Int(cl.Positional.FirstOrDefault()) is { } id ? await PullRequestQueries.ShowAsync(lens, cl.Opt("connection"), id, cl.Opt("repo"), cl.Opt("detail")) - : await PullRequestQueries.ListAsync(lens, cl.Opt("connection"), cl.Opt("project"), cl.Opt("list"))); + : cl.Opt("merged") is { } branch + ? await PullRequestQueries.MergedAsync(lens, cl.Opt("connection"), cl.Opt("project"), branch, cl.Opt("repo"), cl.Opt("since"), Int(cl.Opt("top")) ?? 30) + : await PullRequestQueries.ListAsync(lens, cl.Opt("connection"), cl.Opt("project"), cl.Opt("list"))); return 0; } @@ -852,6 +856,11 @@ public async Task PipelineAsync() var lens = NewLens(); if (cl.Opt("to") is { } to) lens.Settings.DownloadDir = to; var save = cl.Flag("save") || cl.Peek("to") is not null; + if (cl.Flag("deployments")) + { + Print(await PipelineQueries.DeploymentsAsync(lens, cl.Opt("connection"), cl.Opt("project"), cl.Opt("definition"), cl.Opt("stage"), Int(cl.Opt("top")) ?? 10)); + return 0; + } Print(Int(cl.Positional.FirstOrDefault()) is { } build ? cl.Flag("artifacts") || cl.Peek("artifact") is not null ? await PipelineQueries.ArtifactsAsync(lens, cl.Opt("connection"), cl.Opt("project"), build, cl.Opt("artifact"), cl.Opt("part"), save) diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index 7a2a76d..e96dc3f 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -20,8 +20,8 @@ public sealed partial class CommandLine ["logout"] = ["connection=", "all", "remove"], ["connections"] = [], ["trace"] = ["connection=", "detail="], - ["pr"] = ["connection=", "project=", "repo=", "list=", "detail="], - ["pipeline"] = ["connection=", "project=", "definition=", "branch=", "top=", "detail=", "artifacts", "artifact=", "part=", "save", "to="], + ["pr"] = ["connection=", "project=", "repo=", "list=", "detail=", "merged=", "since=", "top="], + ["pipeline"] = ["connection=", "project=", "definition=", "branch=", "top=", "detail=", "artifacts", "artifact=", "part=", "save", "to=", "deployments", "stage="], ["changes"] = ["connection=", "repo=", "file=", "detail="], ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens=", "language=", "label-languages=", "download-dir="], ["item"] = ["connection=", "include=", "detail="], diff --git a/src/AdoLens/Hygiene.cs b/src/AdoLens/Hygiene.cs index f8abf0e..bf5bbde 100644 --- a/src/AdoLens/Hygiene.cs +++ b/src/AdoLens/Hygiene.cs @@ -162,8 +162,10 @@ public static async Task ReleaseNotesAsync(Lens lens, string? connection string title, condition; if (since is not null) { - var from = Formatting.ParseSince(since, day); - var to = until is null ? day : Formatting.ParseSince(until, day); + // A date ("2026-10-01") comes back in UTC; the query and the title work with local days. + static DateTime LocalDay(DateTime d) => (d.Kind == DateTimeKind.Utc ? d.ToLocalTime() : d).Date; + var from = LocalDay(Formatting.ParseSince(since, day)); + var to = until is null ? day : LocalDay(Formatting.ParseSince(until, day)); title = $"{from:yyyy-MM-dd} - {to:yyyy-MM-dd}"; condition = $" AND [Microsoft.VSTS.Common.StateChangeDate] >= '{from:yyyy-MM-dd}' AND [Microsoft.VSTS.Common.StateChangeDate] < '{to.AddDays(1):yyyy-MM-dd}'" + await AreaAsync(boards, name, t, ct); diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 12b9a90..80727e4 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -321,17 +321,22 @@ public static Task ReadFile(Lens lens, string path, public static class PrTools { [McpServerTool(Name = "ado_pr", ReadOnly = true, Idempotent = true)] - [Description("Pull requests: without id the ones waiting for my review and mine (list=active: all active); with id: status, reviewers and votes, checks, work items, open comment threads.")] + [Description("Pull requests: without id the ones waiting for my review and mine (list=active: all active); merged=: those completed into a branch (since, top), with their work items; with id: status, reviewers and votes, checks, work items, open comment threads.")] public static Task Pr(Lens lens, int? id = null, - [Description("Repository name (optional with id).")] string? repo = null, + [Description("Repository name (optional).")] string? repo = null, [Description("review (default) | active.")] string? list = null, + [Description("Target branch, e.g. staging.")] string? merged = null, + [Description("With merged: 7d, 2w or yyyy-MM-dd.")] string? since = null, + int top = 30, [Description("full: all threads, whole description.")] string? detail = null, string? project = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_pr", new { id, repo, list, detail, project, connection }, - () => id is { } n ? PullRequestQueries.ShowAsync(lens, connection, n, repo, detail, ct) : PullRequestQueries.ListAsync(lens, connection, project, list, ct)); + => McpTools.Run("ado_pr", new { id, repo, list, merged, since, top, detail, project, connection }, + () => id is { } n ? PullRequestQueries.ShowAsync(lens, connection, n, repo, detail, ct) + : merged is not null ? PullRequestQueries.MergedAsync(lens, connection, project, merged, repo, since, top, ct) + : PullRequestQueries.ListAsync(lens, connection, project, list, ct)); [McpServerTool(Name = "ado_pr_update")] [Description("Acts on pull requests: create (repo, source, target, title, text, workItems, reviewers, draft), comment (text, optional file and line), reply (thread, text), resolve (thread, status), vote (approve|suggestions|wait|reject|reset), publish, complete, abandon. @[name] in text notifies.")] @@ -368,9 +373,11 @@ public static Task PrUpdate(Lens lens, public static class PipelineTools { [McpServerTool(Name = "ado_pipeline", ReadOnly = true, Idempotent = true)] - [Description("Builds: recent runs (optionally of one pipeline or branch); with build: why it failed - failed steps, errors and the error lines of the log; artifacts=true lists its artifacts, artifact= its files (part= reads one, save=true saves it to disk).")] + [Description("Builds: recent runs (optionally of one pipeline or branch); with build: why it failed - failed steps, errors and the error lines of the log; artifacts=true lists its artifacts, artifact= its files (part= reads one, save=true saves it to disk); deployments=true: recent release deployments (definition, stage) with dates.")] public static Task Pipeline(Lens lens, int? build = null, + bool deployments = false, + [Description("With deployments: stage name, e.g. UAT.")] string? stage = null, bool artifacts = false, string? artifact = null, string? part = null, @@ -382,8 +389,9 @@ public static Task Pipeline(Lens lens, string? project = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_pipeline", new { build, artifacts, artifact, part, save, definition, branch, top, detail, project, connection }, - () => build is { } b + => McpTools.Run("ado_pipeline", new { build, deployments, stage, artifacts, artifact, part, save, definition, branch, top, detail, project, connection }, + () => deployments ? PipelineQueries.DeploymentsAsync(lens, connection, project, definition, stage, top, ct) + : build is { } b ? artifacts || artifact is not null ? PipelineQueries.ArtifactsAsync(lens, connection, project, b, artifact, part, save, ct) : PipelineQueries.BuildAsync(lens, connection, project, b, detail, ct) : PipelineQueries.ListAsync(lens, connection, project, definition, branch, top, ct)); diff --git a/src/AdoLens/Pipelines.cs b/src/AdoLens/Pipelines.cs index ff09789..3028d7d 100644 --- a/src/AdoLens/Pipelines.cs +++ b/src/AdoLens/Pipelines.cs @@ -74,6 +74,43 @@ internal static string Excerpt(string log, int max) return string.Join('\n', hits.Select(x => $"{x.i + 1}: {Formatting.Collapse(x.l, 300)}")); } + /// + /// Recent deployments of classic releases, newest first: definition, stage, release name, when it finished, the + /// build deployed - "when did XYZ reach UAT" (the date then goes into since= of other tools). + /// + public static async Task DeploymentsAsync(Lens lens, string? connection, string? project, string? definition, string? stage, int top, + CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var vsrm = session.Client.ServiceUrl("vsrm") + Uri.EscapeDataString(name) + "/"; + var filter = ""; + if (!string.IsNullOrWhiteSpace(definition)) + { + var defs = await session.Client.GetListAsync($"{vsrm}_apis/release/definitions?searchText={Uri.EscapeDataString(definition.Trim())}", ct); + var def = defs.FirstOrDefault(d => string.Equals(Json.Str(d, "name"), definition.Trim(), StringComparison.OrdinalIgnoreCase)); + if (def.ValueKind != JsonValueKind.Object) def = defs.FirstOrDefault(); + if (def.ValueKind != JsonValueKind.Object) return $"no release definition like '{definition}' in {name}"; + filter = $"&definitionId={def.GetProperty("id").GetInt32()}"; + } + top = Math.Clamp(top, 1, 100); + var list = await session.Client.GetListAsync($"{vsrm}_apis/release/deployments?queryOrder=descending&$top={(stage is null ? top : 200)}{filter}", ct); + var rows = list.Where(d => stage is null || (Json.Str(Json.Obj(d, "releaseEnvironment"), "name") ?? "").Contains(stage.Trim(), StringComparison.OrdinalIgnoreCase)) + .Take(top).ToList(); + if (rows.Count == 0) return $"no deployments found in {name}{(definition is null ? "" : $" for {definition}")}{(stage is null ? "" : $", stage {stage}")}"; + var sb = new StringBuilder($"deployments in {name}, newest first:\n"); + foreach (var d in rows) + { + var when = DateTime.TryParse(Json.Str(d, "completedOn") ?? Json.Str(d, "startedOn") ?? Json.Str(d, "queuedOn"), CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal, out var w) ? w.ToLocalTime().ToString("yyyy-MM-dd HH:mm") : "?"; + var artifact = Json.Arr(Json.Obj(d, "release"), "artifacts").FirstOrDefault(a => Json.Bool(a, "isPrimary")); + var version = Json.Str(Json.Obj(Json.Obj(artifact, "definitionReference"), "version"), "name"); + sb.AppendLine($"- {when} {Json.Str(Json.Obj(d, "releaseDefinition"), "name")} / {Json.Str(Json.Obj(d, "releaseEnvironment"), "name")}: " + + $"{Json.Str(Json.Obj(d, "release"), "name")} {Json.Str(d, "deploymentStatus")}{(version is null ? "" : $", build {version}")}"); + } + return sb.Append("A time here works as since= (e.g. ado_pr merged=staging since=\"2026-10-15 14:30\").").ToString(); + } + const long MaxArtifactRead = 20 * 1024 * 1024; /// diff --git a/src/AdoLens/PullRequests.cs b/src/AdoLens/PullRequests.cs index 01d244e..223d22b 100644 --- a/src/AdoLens/PullRequests.cs +++ b/src/AdoLens/PullRequests.cs @@ -63,6 +63,62 @@ void Section(string title, List prs, int max) return sb.ToString().TrimEnd(); } + /// + /// Pull requests completed into a branch (all repositories of the project, or one), newest first, optionally since a + /// date, each with its work items: "what went into staging this week". + /// + public static async Task MergedAsync(Lens lens, string? connection, string? project, string branch, string? repo, string? since, int top, + CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var target = branch.StartsWith("refs/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch.Trim(); + var from = since is null ? (DateTime?)null : Formatting.ParseSince(since); + top = Math.Clamp(top, 1, 200); + var repoFilter = repo is null ? "" : $"&searchCriteria.repositoryId={(await session.Code.RepoAsync(repo, name, ct)).Id}"; + // The API returns completed PRs newest first; read enough to cover the period, then cut. + var list = await session.Client.GetListAsync( + $"{AdoClient.Scope(name)}_apis/git/pullrequests?searchCriteria.status=completed&searchCriteria.targetRefName={Uri.EscapeDataString(target)}{repoFilter}&$top={(from is null ? top : 500)}", ct); + static DateTime? Closed(JsonElement pr) => + DateTime.TryParse(Json.Str(pr, "closedDate"), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal, out var d) ? d : null; + var prs = list.Where(pr => from is null || Closed(pr) >= from).OrderByDescending(Closed).Take(top).ToList(); + var label = $"{Code.Short(target)}{(repo is null ? "" : $" ({repo})")}"; + if (prs.Count == 0) return $"no pull requests completed into {label} in {name}{(from is null ? "" : $" since {Since(from.Value)}")}"; + + var items = await Task.WhenAll(prs.Select(async pr => + { + try + { + var linked = await session.Client.GetListAsync($"_apis/git/repositories/{Json.Str(Json.Obj(pr, "repository"), "id")}/pullRequests/{pr.GetProperty("pullRequestId").GetInt32()}/workitems", ct); + return linked.Select(w => int.TryParse(Json.Str(w, "id"), out var i) ? i : 0).Where(i => i > 0).ToList(); + } + catch (AdoApiException) { return []; } + })); + var ids = items.SelectMany(x => x).Distinct().ToList(); + // Type, state and tags with each work item: enough for a table without asking for every item again. + var heads = ids.Count == 0 ? [] : (await session.Work.GetAsync(ids, relations: false, ["System.Id", "System.WorkItemType", "System.State", "System.Title", "System.Tags"], ct)) + .ToDictionary(WorkItems.Id, w => $"#{WorkItems.Id(w)} {WorkItems.Field(w, "System.WorkItemType")} {Formatting.Collapse(WorkItems.Field(w, "System.Title") ?? "", 50)} ({WorkItems.Field(w, "System.State")})" + + (WorkItems.Field(w, "System.Tags") is { Length: > 0 } tags ? $" [tags: {tags}]" : "")); + var sb = new StringBuilder($"{prs.Count} pull request(s) completed into {label} in {name}{(from is null ? "" : $" since {Since(from.Value)}")}, newest first:\n"); + for (var i = 0; i < prs.Count; i++) + { + var pr = prs[i]; + var wi = items[i].Where(heads.ContainsKey).Select(x => heads[x]).ToList(); + sb.AppendLine($"- PR {pr.GetProperty("pullRequestId").GetInt32()} {Closed(pr):yyyy-MM-dd} {Json.Str(Json.Obj(pr, "repository"), "name")}: " + + $"{Formatting.Collapse(Json.Str(pr, "title") ?? "", 70)} ({Json.Str(Json.Obj(pr, "createdBy"), "displayName")}, from {Formatting.Collapse(Code.Short(Json.Str(pr, "sourceRefName")) ?? "", 40)})" + + (wi.Count > 0 ? " -> " + string.Join("; ", wi) : " - no work item")); + } + if (list.Count >= (from is null ? top : 500) && prs.Count == top) sb.Append($"(top={top}; more with a higher top or a later since)"); + return sb.ToString().TrimEnd(); + } + + /// The local day, with the time when one was given ("2026-10-15 14:30"). + static string Since(DateTime utc) + { + var local = utc.ToLocalTime(); + return local.TimeOfDay == TimeSpan.Zero ? local.ToString("yyyy-MM-dd") : local.ToString("yyyy-MM-dd HH:mm"); + } + static async Task GetAsync(Session session, string? repo, int id, CancellationToken ct) { var repoId = repo is null ? null : (await session.Code.RepoAsync(repo, session.Config.Project, ct)).Id; diff --git a/tests/AdoLens.Tests/MergedPrTests.cs b/tests/AdoLens.Tests/MergedPrTests.cs new file mode 100644 index 0000000..ac676fd --- /dev/null +++ b/tests/AdoLens.Tests/MergedPrTests.cs @@ -0,0 +1,45 @@ +namespace AdoLens.Tests; + +public class MergedPrTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-merged-{Guid.NewGuid():N}"); + readonly FakeAdo ado; + readonly Lens lens; + + public MergedPrTests() + { + ado = new DemoItems().Routes(new DemoProject().Routes(new FakeAdo())) + .On("/contoso/Demo/_apis/git/pullrequests", """ + {"value":[ + {"pullRequestId":12,"title":"Login form","closedDate":"2026-10-14T10:00:00Z","repository":{"id":"r1","name":"portal"}, + "createdBy":{"displayName":"Anna Nowak"},"sourceRefName":"refs/heads/feature/login"}, + {"pullRequestId":11,"title":"Old change","closedDate":"2026-09-01T10:00:00Z","repository":{"id":"r1","name":"portal"}, + "createdBy":{"displayName":"Ben Kowalski"},"sourceRefName":"refs/heads/feature/old"}]} + """) + .On("/contoso/_apis/git/repositories/r1/pullRequests/12/workitems", """{"value":[{"id":"1"}]}""") + .On("/contoso/_apis/git/repositories/r1/pullRequests/11/workitems", """{"value":[]}"""); + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public async Task Pull_requests_merged_into_a_branch_come_with_their_work_items() + { + var all = await PullRequestQueries.MergedAsync(lens, null, null, "staging", null, null, 30); + Assert.Contains("2 pull request(s) completed into staging in Demo", all); + Assert.Contains("- PR 12 2026-10-14 portal: Login form (Anna Nowak, from feature/login) -> #1 Product Backlog Item Login page (New) [tags: UAT]", all); + Assert.Contains("- PR 11 2026-09-01 portal: Old change (Ben Kowalski, from feature/old) - no work item", all); + Assert.Contains(ado.Requests, r => r.Contains("searchCriteria.status=completed") && r.Contains("searchCriteria.targetRefName=refs/heads/staging")); + + var recent = await PullRequestQueries.MergedAsync(lens, null, null, "staging", null, "2026-10-01", 30); + Assert.Contains("1 pull request(s) completed into staging in Demo since 2026-10-01", recent); + Assert.DoesNotContain("PR 11", recent); + } +}