diff --git a/README.md b/README.md index 08c0607..5dfc7ff 100644 --- a/README.md +++ b/README.md @@ -197,3 +197,29 @@ trello-cli --delete-checklist-item ## License This project is licensed under the [MIT License](LICENSE). + +## Card position + +Pass `--pos top`, `--pos bottom`, or a positive numeric position (for example, +`--pos 123.5`) when creating, moving, or updating a card: + +```bash +trello-cli --create-card "New task" --pos top +trello-cli --move-card --pos bottom +trello-cli --update-card --pos 123.5 +``` + +Creation sends the position in the same request as the new card. Moving applies +it within the destination list; updating changes the position in the current +list without requiring another field. Omitting `--pos` preserves the existing +behavior. Numeric positions use a decimal point, not a comma. Missing, invalid, +or repeated values return `INVALID_PARAM` and exit code 1 before an API request. + +Run the offline regression checks with .NET 10: + +```bash +dotnet run --project tests/PositionTests.csproj +``` + +The checks intercept HTTP requests and use dummy credentials; they do not +create or modify real Trello cards. diff --git a/src/Commands/CardCommands.cs b/src/Commands/CardCommands.cs index c2b9b49..fcb5bbd 100644 --- a/src/Commands/CardCommands.cs +++ b/src/Commands/CardCommands.cs @@ -49,7 +49,7 @@ public async Task GetCardAsync(string cardId) OutputFormatter.Print(result); } - public async Task CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null) + public async Task CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null, string? pos = null) { if (string.IsNullOrEmpty(listId)) { @@ -63,11 +63,11 @@ public async Task CreateCardAsync(string listId, string name, string? desc = nul return; } - var result = await _api.CreateCardAsync(listId, name, desc, due, labels, members); + var result = await _api.CreateCardAsync(listId, name, desc, due, labels, members, pos); OutputFormatter.Print(result); } - public async Task UpdateCardAsync(string cardId, string? name, string? desc, string? due, string? labels, string? members, bool? closed = null) + public async Task UpdateCardAsync(string cardId, string? name, string? desc, string? due, string? labels, string? members, bool? closed = null, string? pos = null) { if (string.IsNullOrEmpty(cardId)) { @@ -75,7 +75,7 @@ public async Task UpdateCardAsync(string cardId, string? name, string? desc, str return; } - var result = await _api.UpdateCardAsync(cardId, name, desc, due, null, labels, members, closed); + var result = await _api.UpdateCardAsync(cardId, name, desc, due, null, labels, members, closed, pos); OutputFormatter.Print(result); } @@ -103,7 +103,7 @@ public async Task UnarchiveCardAsync(string cardId) OutputFormatter.Print(result); } - public async Task MoveCardAsync(string cardId, string listId) + public async Task MoveCardAsync(string cardId, string listId, string? pos = null) { if (string.IsNullOrEmpty(cardId)) { @@ -117,7 +117,7 @@ public async Task MoveCardAsync(string cardId, string listId) return; } - var result = await _api.MoveCardAsync(cardId, listId); + var result = await _api.MoveCardAsync(cardId, listId, pos); OutputFormatter.Print(result); } diff --git a/src/Program.cs b/src/Program.cs index 81cb1ee..49987e5 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -3,7 +3,7 @@ using TrelloCli.Services; using TrelloCli.Utils; -const string Version = "1.1.0"; +const string Version = "1.2.0"; var config = new ConfigService(); @@ -82,6 +82,17 @@ async Task ExecuteCommand(string[] args) { var command = args[0]; + string? position = null; + if (command is "--create-card" or "--update-card" or "--move-card") + { + if (!CardPosition.TryRead(args, out position, out var positionError)) + { + OutputFormatter.Print(ApiResponse.Fail(positionError!, "INVALID_PARAM")); + Environment.ExitCode = 1; + return; + } + } + switch (command) { // Auth @@ -142,7 +153,8 @@ await cardCmd.CreateCardAsync( GetNamedArg(args, "--desc"), GetNamedArg(args, "--due"), GetNamedArg(args, "--labels"), - GetNamedArg(args, "--members") + GetNamedArg(args, "--members"), + position ); break; @@ -161,7 +173,8 @@ await cardCmd.UpdateCardAsync( GetNamedArg(args, "--due"), GetNamedArg(args, "--labels"), GetNamedArg(args, "--members"), - closedValue + closedValue, + position ); break; @@ -174,7 +187,7 @@ await cardCmd.UpdateCardAsync( break; case "--move-card": - await cardCmd.MoveCardAsync(GetArg(args, 1), GetArg(args, 2)); + await cardCmd.MoveCardAsync(GetArg(args, 1), GetArg(args, 2), position); break; case "--delete-card": @@ -333,6 +346,7 @@ void ShowHelp() [--due ] [--labels ] Comma-separated label IDs [--members ] Comma-separated member IDs + [--pos ] Position in the list --update-card Update card [--name ] [--desc ] @@ -340,9 +354,11 @@ void ShowHelp() [--labels ] [--members ] [--closed ] Archive/unarchive card + [--pos ] Reorder within the current list --archive-card Archive card (shortcut for --update-card --closed true) --unarchive-card Unarchive card (shortcut for --update-card --closed false) --move-card Move card to list + [--pos ] Position in the destination list --delete-card Delete card --get-comments Get comments on a card --add-comment Add comment to a card @@ -382,6 +398,8 @@ requires browser authentication. Use --attach-url to link attachments. trello-cli --get-boards trello-cli --get-board abc123 trello-cli --create-card xyz789 ""My Task"" --desc ""Details"" - trello-cli --move-card card123 list456 + trello-cli --move-card card123 list456 --pos top + trello-cli --update-card card123 --pos bottom + trello-cli --create-card xyz789 ""New task"" --pos top "); } diff --git a/src/Services/TrelloApiService.cs b/src/Services/TrelloApiService.cs index 1e83e1b..8ea875b 100644 --- a/src/Services/TrelloApiService.cs +++ b/src/Services/TrelloApiService.cs @@ -217,7 +217,7 @@ public async Task> GetCardAsync(string cardId) } } - public async Task> CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null) + public async Task> CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null, string? pos = null) { try { @@ -236,6 +236,8 @@ public async Task> CreateCardAsync(string listId, string name, formData["idLabels"] = labels; if (!string.IsNullOrEmpty(members)) formData["idMembers"] = members; + if (pos != null) + formData["pos"] = pos; var content = new FormUrlEncodedContent(formData); var response = await _http.PostAsync(url, content); @@ -257,7 +259,7 @@ public async Task> CreateCardAsync(string listId, string name, } public async Task> UpdateCardAsync(string cardId, string? name = null, string? desc = null, - string? due = null, string? listId = null, string? labels = null, string? members = null, bool? closed = null) + string? due = null, string? listId = null, string? labels = null, string? members = null, bool? closed = null, string? pos = null) { try { @@ -274,6 +276,8 @@ public async Task> UpdateCardAsync(string cardId, string? name formData["idLabels"] = labels; if (members != null) formData["idMembers"] = members; + if (pos != null) + formData["pos"] = pos; if (closed.HasValue) formData["closed"] = closed.Value.ToString().ToLower(); @@ -304,9 +308,9 @@ public async Task> UpdateCardAsync(string cardId, string? name } } - public async Task> MoveCardAsync(string cardId, string listId) + public async Task> MoveCardAsync(string cardId, string listId, string? pos = null) { - return await UpdateCardAsync(cardId, listId: listId); + return await UpdateCardAsync(cardId, listId: listId, pos: pos); } public async Task> DeleteCardAsync(string cardId) diff --git a/src/TrelloCli.csproj b/src/TrelloCli.csproj index 636e652..adb48ea 100644 --- a/src/TrelloCli.csproj +++ b/src/TrelloCli.csproj @@ -13,7 +13,7 @@ TrelloCli - 1.1.0 + 1.2.0 Aygün Özgür CLI tool for Trello with AI-friendly JSON output diff --git a/src/Utils/CardPosition.cs b/src/Utils/CardPosition.cs new file mode 100644 index 0000000..02e3244 --- /dev/null +++ b/src/Utils/CardPosition.cs @@ -0,0 +1,33 @@ +using System.Globalization; + +namespace TrelloCli.Utils; + +public static class CardPosition +{ + public static bool TryRead(string[] args, out string? position, out string? error) + { + position = null; + error = null; + var firstOption = args[0] == "--update-card" ? 2 : 3; + for (var i = firstOption; i < args.Length; i++) + { + if (args[i] != "--pos") + { + if (args[i] is "--name" or "--desc" or "--due" or "--labels" or "--members" or "--closed") + i++; + continue; + } + if (position != null || i + 1 >= args.Length || !IsValid(args[i + 1])) + { + error = "--pos requires top, bottom, or a positive finite number (decimal point: .); specify it once"; + return false; + } + position = args[++i]; + } + return true; + } + + private static bool IsValid(string value) => value is "top" or "bottom" || + (double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number) + && double.IsFinite(number) && number > 0); +} diff --git a/tests/PositionTests.csproj b/tests/PositionTests.csproj new file mode 100644 index 0000000..a61a051 --- /dev/null +++ b/tests/PositionTests.csproj @@ -0,0 +1 @@ +Exenet10.0enableenable \ No newline at end of file diff --git a/tests/Program.cs b/tests/Program.cs new file mode 100644 index 0000000..db23ead --- /dev/null +++ b/tests/Program.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using TrelloCli.Commands; +using TrelloCli.Services; +using TrelloCli.Utils; + +Environment.SetEnvironmentVariable("TRELLO_API_KEY", "test-key"); +Environment.SetEnvironmentVariable("TRELLO_TOKEN", "test-token"); +var handler = new CaptureHandler(); +var api = new TrelloApiService(new ConfigService()); +typeof(TrelloApiService).GetField("_http", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(api, new HttpClient(handler)); +var commands = new CardCommands(api); +var count = 0; +foreach (var pos in new string?[] { "top", "bottom", "123.5", null }) +{ + foreach (var operation in new[] { "create", "move", "update" }) + { + handler.Calls = 0; + var output = new StringWriter(); + var original = Console.Out; + Console.SetOut(output); + try + { + if (operation == "create") await commands.CreateCardAsync("list", "Task", pos: pos); + if (operation == "move") await commands.MoveCardAsync("card", "list", pos: pos); + if (operation == "update") await commands.UpdateCardAsync("card", "Task", null, null, null, null, pos: pos); + } + finally { Console.SetOut(original); } + Check(JsonDocument.Parse(output.ToString()).RootElement.GetProperty("ok").GetBoolean(), "command failed"); + Check(handler.Calls == 1, "must use exactly one request"); + Check(handler.Method == (operation == "create" ? HttpMethod.Post : HttpMethod.Put), "wrong method"); + Check(handler.Path == (operation == "create" ? "/1/cards" : "/1/cards/card"), "wrong endpoint"); + var fields = handler.Body.Split('&').Select(x => x.Split('=', 2)).ToDictionary(x => x[0], x => Uri.UnescapeDataString(x[1])); + Check(pos == null ? !fields.ContainsKey("pos") : fields.GetValueOrDefault("pos") == pos, "position not sent correctly"); + if (operation != "update") Check(fields.GetValueOrDefault("idList") == "list", "list missing"); + count++; + } +} +handler.Calls = 0; +await commands.UpdateCardAsync("card", null, null, null, null, null, pos: "top"); +Check(handler.Calls == 1, "position-only update must send request"); +foreach (var value in new[] { "top", "bottom", "123.5", "0.25", "140737488470016" }) + Check(CardPosition.TryRead(["--update-card", "card", "--pos", value], out var position, out _) && position == value, "valid position rejected"); +foreach (var value in new[] { "", "middle", "0", "-1", "NaN", "Infinity", "1,5", "--name", "1e999", " " }) + Check(!CardPosition.TryRead(["--update-card", "card", "--pos", value], out _, out _), "invalid position accepted"); +Check(!CardPosition.TryRead(["--move-card", "card", "list", "--pos"], out _, out _), "missing position accepted"); +Check(!CardPosition.TryRead(["--update-card", "card", "--pos", "top", "--pos", "bottom"], out _, out _), "duplicate position accepted"); +Check(CardPosition.TryRead(["--create-card", "list", "Task"], out var absent, out _) && absent == null, "optional position required"); +foreach (var arguments in new[] { + new[] { "--create-card", "list", "--pos" }, + new[] { "--update-card", "card", "--desc", "--pos" }, + new[] { "--create-card", "list", "Task", "--desc", "--pos" } +}) + Check(CardPosition.TryRead(arguments, out var literal, out _) && literal == null, "literal option text was parsed as position"); +Check(CardPosition.TryRead(["--create-card", "list", "--pos", "--desc", "--pos", "--pos", "top"], out var mixed, out _) && mixed == "top", "position after literal text was lost"); +Console.WriteLine($"PASS: {count + 1} HTTP request checks, 22 position parsing checks"); + +static void Check(bool ok, string message) { if (!ok) throw new Exception(message); } +sealed class CaptureHandler : HttpMessageHandler +{ + public int Calls; + public string Body = ""; + public string Path = ""; + public HttpMethod? Method; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + Path = request.RequestUri!.AbsolutePath; + Method = request.Method; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"card\",\"name\":\"Task\",\"pos\":123.5}") }; + } +}