diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ce1c1..7d08887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `bgit` are documented in this file. This project follows semantic versioning. +## 1.3.4 + +Fixed + +- Local broker repositories now support issue and task-board endpoints, so + `bgit issue` and `bgit board` commands work for local broker clones. +- Broker commands now preserve local broker S3/GCS storage settings from + `.git/config` when resolving the backing repository. +- Added `bgit board priority STORY_ID ORDER [--lane LANE]`; board reordering now + normalizes lane order to dense story positions so setting a story to priority + 3 shifts the existing 3+ stories down in one broker mutation. + ## 1.3.3 Fixed diff --git a/README.md b/README.md index 42561d4..c9c5188 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ bgit board edit BG-1 "As a maintainer, I want clear setup docs so that new users bgit board take BG-1 bgit board assign BG-1 ada bgit board move BG-1 doing +bgit board priority BG-1 1 bgit board comment BG-1 "Opened PR #2." bgit board archive BG-1 bgit board list --archived @@ -430,6 +431,7 @@ bgit board edit BG-1 "As a developer, I want CI logs and status on each run so t bgit board take BG-1 bgit board assign BG-1 ada bgit board move BG-1 review +bgit board priority BG-1 2 --lane review bgit board comment BG-1 "PR #4 is ready for review." bgit board archive BG-1 bgit board list --archived diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index 9302360..166c7c2 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -1742,28 +1742,64 @@ Resources: Number(issue.id || 0) !== Number(excludeID || 0) && normalizeBoardLane(issue.lane) === lane ); - const max = stories.reduce((value, issue) => Math.max(value, storyPosition(issue)), 0); - return max + 1000; + return stories.length + 1; } - async function storyPositionAfter(entry, lane, afterID, selfID) { - const stories = sortStoriesForLane((await listIssues(entry)).filter((issue) => - issue.type === "story" && - !issue.archived && - Number(issue.id || 0) !== Number(selfID || 0) && - normalizeBoardLane(issue.lane) === lane - )); - if (!stories.length) return 1000; - const after = Number(afterID || 0); - if (!after) { - const first = storyPosition(stories[0]); - return first > 1 ? first / 2 : 500; + function insertStoryIndex(stories, afterID, order) { + const requestedOrder = Number(order || 0); + if (Number.isFinite(requestedOrder) && requestedOrder > 0) { + return Math.max(0, Math.min(stories.length, Math.floor(requestedOrder) - 1)); } + const after = Number(afterID || 0); + if (!after) return 0; const index = stories.findIndex((issue) => Number(issue.id || 0) === after); - if (index < 0) return nextStoryPosition(entry, lane, selfID); - const current = storyPosition(stories[index]) || ((index + 1) * 1000); - const next = stories[index + 1] ? storyPosition(stories[index + 1]) : 0; - if (!next) return current + 1000; - return current + ((next - current) / 2); + return index < 0 ? stories.length : index + 1; + } + async function reorderStory(entry, issue, lane, afterID, order) { + const oldLane = normalizeBoardLane(issue.lane); + const all = await listIssues(entry); + const changed = []; + const targetStories = sortStoriesForLane(all.filter((candidate) => + candidate.type === "story" && + !candidate.archived && + Number(candidate.id || 0) !== Number(issue.id || 0) && + normalizeBoardLane(candidate.lane) === lane + )); + const index = insertStoryIndex(targetStories, afterID, order); + issue.lane = lane; + targetStories.splice(index, 0, issue); + for (let i = 0; i < targetStories.length; i++) { + const story = targetStories[i]; + const position = i + 1; + if (normalizeBoardLane(story.lane) !== lane || storyPosition(story) !== position) { + story.lane = lane; + story.position = position; + story.updated_at = new Date().toISOString(); + changed.push(story); + } + } + if (oldLane !== lane) { + const oldStories = sortStoriesForLane(all.filter((candidate) => + candidate.type === "story" && + !candidate.archived && + Number(candidate.id || 0) !== Number(issue.id || 0) && + normalizeBoardLane(candidate.lane) === oldLane + )); + for (let i = 0; i < oldStories.length; i++) { + const story = oldStories[i]; + const position = i + 1; + if (storyPosition(story) !== position) { + story.position = position; + story.updated_at = new Date().toISOString(); + changed.push(story); + } + } + } + return changed; + } + async function saveIssues(entry, issues) { + const unique = new Map(); + for (const issue of issues) unique.set(Number(issue.id || 0), issue); + await Promise.all([...unique.values()].map((issue) => saveIssue(entry, issue))); } function nextCIID(data) { data.next_ci_id = Number(data.next_ci_id || 1); @@ -2692,7 +2728,12 @@ Resources: const issue = {id: nextIssueID(entry.data), type: story ? "story" : "issue", title, body: issueBody, status: "open", lane, assignee: "", position: story ? await nextStoryPosition(entry, lane) : 0, archived: false, author: key.user || "anonymous", comments: [], history: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; if (story) issueHistory(issue, key.user, "created", {to: lane}); await saveRepo(entry); - await saveIssue(entry, issue); + if (story) { + const changed = await reorderStory(entry, issue, lane, undefined, 0); + await saveIssues(entry, changed.concat([issue])); + } else { + await saveIssue(entry, issue); + } return response(200, {ok: true, issue}); } if (path === "/issues/comment" && method === "POST") { @@ -2750,11 +2791,9 @@ Resources: if (!issue || issue.type !== "story") throw Object.assign(new Error("story not found"), {statusCode: 404}); const fromLane = normalizeBoardLane(issue.lane); const lane = normalizeBoardLane(body.lane || issue.lane); - issue.lane = lane; - issue.position = await storyPositionAfter(entry, lane, body.after_id, issue.id); - issue.updated_at = new Date().toISOString(); + const changed = await reorderStory(entry, issue, lane, body.after_id, body.order); issueHistory(issue, key.user, "reordered", {from: fromLane, to: lane, position: String(issue.position)}); - await saveIssue(entry, issue); + await saveIssues(entry, changed.concat([issue])); return response(200, {ok: true, issue}); } if (path === "/issues/move" || path === "/issues/take" || path === "/issues/assign") { @@ -2767,16 +2806,22 @@ Resources: const fromAssignee = issue.assignee || ""; if (path === "/issues/move") { const lane = normalizeBoardLane(body.lane); - issue.lane = lane; - if (Object.prototype.hasOwnProperty.call(body, "after_id")) issue.position = await storyPositionAfter(entry, lane, body.after_id, issue.id); - else if (fromLane !== lane || !storyPosition(issue)) issue.position = await nextStoryPosition(entry, lane, issue.id); + const changed = Object.prototype.hasOwnProperty.call(body, "after_id") + ? await reorderStory(entry, issue, lane, body.after_id, body.order) + : await reorderStory(entry, issue, lane, undefined, 0); issueHistory(issue, key.user, "moved", {from: fromLane, to: lane}); + await saveIssues(entry, changed.concat([issue])); + return response(200, {ok: true, issue}); } else if (path === "/issues/take") { issue.assignee = key.user || ""; if (normalizeBoardLane(issue.lane) === "backlog") issue.lane = "doing"; - if (fromLane !== normalizeBoardLane(issue.lane) || !storyPosition(issue)) issue.position = await nextStoryPosition(entry, normalizeBoardLane(issue.lane), issue.id); + const lane = normalizeBoardLane(issue.lane); + const changed = (fromLane !== lane || !storyPosition(issue)) ? await reorderStory(entry, issue, lane, undefined, 0) : []; issueHistory(issue, key.user, "assigned", {from: fromAssignee, to: issue.assignee || ""}); + issue.updated_at = new Date().toISOString(); + await saveIssues(changed.concat([issue])); + return response(200, {ok: true, issue}); } else { const assignee = String(body.assignee || "").trim(); if (assignee) { diff --git a/broker/gcp/index.js b/broker/gcp/index.js index 57cae23..8bb36c6 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -1107,29 +1107,67 @@ async function nextStoryPosition(entry, lane, excludeID = 0) { Number(issue.id || 0) !== Number(excludeID || 0) && normalizeBoardLane(issue.lane) === lane ); - const max = stories.reduce((value, issue) => Math.max(value, storyPosition(issue)), 0); - return max + 1000; + return stories.length + 1; } -async function storyPositionAfter(entry, lane, afterID, selfID) { - const stories = sortStoriesForLane((await listIssues(entry)).filter((issue) => - issue.type === 'story' && - !issue.archived && - Number(issue.id || 0) !== Number(selfID || 0) && - normalizeBoardLane(issue.lane) === lane - )); - if (!stories.length) return 1000; - const after = Number(afterID || 0); - if (!after) { - const first = storyPosition(stories[0]); - return first > 1 ? first / 2 : 500; +function insertStoryIndex(stories, afterID, order) { + const requestedOrder = Number(order || 0); + if (Number.isFinite(requestedOrder) && requestedOrder > 0) { + return Math.max(0, Math.min(stories.length, Math.floor(requestedOrder) - 1)); } + const after = Number(afterID || 0); + if (!after) return 0; const index = stories.findIndex((issue) => Number(issue.id || 0) === after); - if (index < 0) return nextStoryPosition(entry, lane, selfID); - const current = storyPosition(stories[index]) || ((index + 1) * 1000); - const next = stories[index + 1] ? storyPosition(stories[index + 1]) : 0; - if (!next) return current + 1000; - return current + ((next - current) / 2); + return index < 0 ? stories.length : index + 1; +} + +async function reorderStory(entry, issue, lane, afterID, order) { + const oldLane = normalizeBoardLane(issue.lane); + const all = await listIssues(entry); + const changed = []; + const targetStories = sortStoriesForLane(all.filter((candidate) => + candidate.type === 'story' && + !candidate.archived && + Number(candidate.id || 0) !== Number(issue.id || 0) && + normalizeBoardLane(candidate.lane) === lane + )); + const index = insertStoryIndex(targetStories, afterID, order); + issue.lane = lane; + targetStories.splice(index, 0, issue); + for (let i = 0; i < targetStories.length; i++) { + const story = targetStories[i]; + const position = i + 1; + if (normalizeBoardLane(story.lane) !== lane || storyPosition(story) !== position) { + story.lane = lane; + story.position = position; + story.updated_at = new Date().toISOString(); + changed.push(story); + } + } + if (oldLane !== lane) { + const oldStories = sortStoriesForLane(all.filter((candidate) => + candidate.type === 'story' && + !candidate.archived && + Number(candidate.id || 0) !== Number(issue.id || 0) && + normalizeBoardLane(candidate.lane) === oldLane + )); + for (let i = 0; i < oldStories.length; i++) { + const story = oldStories[i]; + const position = i + 1; + if (storyPosition(story) !== position) { + story.position = position; + story.updated_at = new Date().toISOString(); + changed.push(story); + } + } + } + return changed; +} + +async function saveIssues(entry, issues) { + const unique = new Map(); + for (const issue of issues) unique.set(Number(issue.id || 0), issue); + await Promise.all([...unique.values()].map((issue) => saveIssue(entry, issue))); } function nextCIID(data) { @@ -2239,7 +2277,12 @@ exports.broker = async (req, res) => { const issue = {id: nextIssueID(entry.data), type: story ? 'story' : 'issue', title, body: issueBody, status: 'open', lane, assignee: '', position: story ? await nextStoryPosition(entry, lane) : 0, archived: false, author: key.user || 'anonymous', comments: [], history: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; if (story) issueHistory(issue, key.user, 'created', {to: lane}); await saveRepo(entry); - await saveIssue(entry, issue); + if (story) { + const changed = await reorderStory(entry, issue, lane, undefined, 0); + await saveIssues(entry, changed.concat([issue])); + } else { + await saveIssue(entry, issue); + } res.status(200).send(JSON.stringify({ok: true, issue})); return; } @@ -2302,11 +2345,9 @@ exports.broker = async (req, res) => { if (!issue || issue.type !== 'story') throw Object.assign(new Error('story not found'), {status: 404}); const fromLane = normalizeBoardLane(issue.lane); const lane = normalizeBoardLane(body.lane || issue.lane); - issue.lane = lane; - issue.position = await storyPositionAfter(entry, lane, body.after_id, issue.id); - issue.updated_at = new Date().toISOString(); + const changed = await reorderStory(entry, issue, lane, body.after_id, body.order); issueHistory(issue, key.user, 'reordered', {from: fromLane, to: lane, position: String(issue.position)}); - await saveIssue(entry, issue); + await saveIssues(entry, changed.concat([issue])); res.status(200).send(JSON.stringify({ok: true, issue})); return; } @@ -2320,16 +2361,24 @@ exports.broker = async (req, res) => { const fromAssignee = issue.assignee || ''; if (req.path === '/issues/move') { const lane = normalizeBoardLane(body.lane); - issue.lane = lane; - if (Object.prototype.hasOwnProperty.call(body, 'after_id')) issue.position = await storyPositionAfter(entry, lane, body.after_id, issue.id); - else if (fromLane !== lane || !storyPosition(issue)) issue.position = await nextStoryPosition(entry, lane, issue.id); + const changed = Object.prototype.hasOwnProperty.call(body, 'after_id') + ? await reorderStory(entry, issue, lane, body.after_id, body.order) + : await reorderStory(entry, issue, lane, undefined, 0); issueHistory(issue, key.user, 'moved', {from: fromLane, to: lane}); + await saveIssues(entry, changed.concat([issue])); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; } else if (req.path === '/issues/take') { issue.assignee = key.user || ''; if (normalizeBoardLane(issue.lane) === 'backlog') issue.lane = 'doing'; - if (fromLane !== normalizeBoardLane(issue.lane) || !storyPosition(issue)) issue.position = await nextStoryPosition(entry, normalizeBoardLane(issue.lane), issue.id); + const lane = normalizeBoardLane(issue.lane); + const changed = (fromLane !== lane || !storyPosition(issue)) ? await reorderStory(entry, issue, lane, undefined, 0) : []; issueHistory(issue, key.user, 'assigned', {from: fromAssignee, to: issue.assignee || ''}); + issue.updated_at = new Date().toISOString(); + await saveIssues(changed.concat([issue])); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; } else { const assignee = String(body.assignee || '').trim(); if (assignee) { diff --git a/broker_commands.go b/broker_commands.go index 79e0efe..8cd78cd 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -2870,7 +2870,7 @@ func issueCommand(args []string, stdin io.Reader, stdout io.Writer) error { func boardCommand(args []string, stdin io.Reader, stdout io.Writer) error { _ = stdin if len(args) == 0 { - return errors.New("usage: bgit board list|create|edit|move|take|assign|archive|unarchive|comment [args]") + return errors.New("usage: bgit board list|create|edit|move|priority|take|assign|archive|unarchive|comment [args]") } cfg, err := configForBrokerCommand(config{}) if err != nil { @@ -2990,6 +2990,20 @@ func boardCommand(args []string, stdin io.Reader, stdout io.Writer) error { } fmt.Fprintf(stdout, "moved story %s to %s\n", storyDisplayID(monogram, id), lane) return nil + case "priority", "order", "reorder": + id, order, lane, err := parseBoardPriorityArgs(args[1:], monogram) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/issues/reorder", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Lane: lane, Order: order}, nil); err != nil { + return boardUpgradeError(err) + } + if lane == "" { + fmt.Fprintf(stdout, "set story %s to priority %d\n", storyDisplayID(monogram, id), order) + } else { + fmt.Fprintf(stdout, "set story %s to priority %d in %s\n", storyDisplayID(monogram, id), order, lane) + } + return nil case "take": id, err := parseBoardStoryIDArg(args, monogram) if err != nil { @@ -3062,6 +3076,39 @@ func parseBoardStoryIDArg(args []string, monogram string) (int, error) { return parseStoryDisplayID(args[1], monogram) } +func parseBoardPriorityArgs(args []string, monogram string) (int, int, string, error) { + if len(args) < 2 { + return 0, 0, "", errors.New("usage: bgit board priority STORY_ID ORDER [--lane backlog|ready|doing|review|done]") + } + id, err := parseStoryDisplayID(args[0], monogram) + if err != nil { + return 0, 0, "", err + } + order, err := strconv.Atoi(args[1]) + if err != nil || order <= 0 { + return 0, 0, "", errors.New("priority order must be a positive number") + } + lane := "" + for i := 2; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--lane": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return 0, 0, "", err + } + lane, err = parseKanbanLane(value) + if err != nil { + return 0, 0, "", err + } + default: + return 0, 0, "", fmt.Errorf("unsupported board priority option %s", arg) + } + } + return id, order, lane, nil +} + func boardUpgradeError(err error) error { if err == nil { return nil diff --git a/broker_commands_test.go b/broker_commands_test.go index df10a28..bd12a94 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -429,6 +429,41 @@ func TestBoardCommandEditsAndArchivesStories(t *testing.T) { } } +func TestBoardCommandPrioritizesStory(t *testing.T) { + var reorderReq brokerIssueRequest + target, server, requests := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issues/reorder" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&reorderReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer server.Close() + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := boardCommand([]string{"priority", "AP-7", "3", "--lane", "review"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if reorderReq.ID != 7 || reorderReq.Order != 3 || reorderReq.Lane != "review" { + t.Fatalf("reorder req = %#v", reorderReq) + } + if !strings.Contains(stdout.String(), "set story AP-7 to priority 3 in review") { + t.Fatalf("stdout = %q", stdout.String()) + } + if got := strings.Join(*requests, ","); got != "/issues/reorder" { + t.Fatalf("requests = %s", got) + } +} + func TestBoardCommandListsArchivedStoriesSeparately(t *testing.T) { var listReq brokerIssueRequest target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/local_broker_native.go b/local_broker_native.go index 633499e..0ef56d1 100644 --- a/local_broker_native.go +++ b/local_broker_native.go @@ -46,6 +46,11 @@ type localBrokerRepoIndex struct { Repos []brokerRepo `json:"repos"` } +type localBrokerIssueStore struct { + NextID int `json:"next_id"` + Issues []brokerIssue `json:"issues"` +} + func isLocalBrokerURL(value string) bool { return strings.HasPrefix(strings.TrimSpace(value), "local://") } @@ -250,6 +255,13 @@ func (s *localBrokerServer) localPost(path string, req any) ([]byte, int, error) return nil, 500, err } return mustJSON(brokerObjectResponse{Paths: paths}), 200, nil + case "/issues/list", "/issues/view", "/issues/create", "/issues/update", "/issues/comment", "/issues/close", "/issues/reopen", "/issues/move", "/issues/take", "/issues/assign", "/issues/archive", "/issues/reorder": + r := req.(brokerIssueRequest) + state, err := s.loadRepoForRequest(r.Repo) + if err != nil { + return mustJSON(map[string]string{"error": "repository not found"}), http.StatusNotFound, nil + } + return s.localIssueEndpoint(path, r, state, "owner") case "/refs/update": r := req.(brokerRefUpdateRequest) state, err := s.loadRepoForRequest(r.Repo) @@ -327,6 +339,8 @@ func (s *localBrokerServer) handle(w http.ResponseWriter, r *http.Request) { s.handleObjectsRead(w, r, body) case "/objects/list": s.handleObjectsList(w, r, body) + case "/issues/list", "/issues/view", "/issues/create", "/issues/update", "/issues/comment", "/issues/close", "/issues/reopen", "/issues/move", "/issues/take", "/issues/assign", "/issues/archive", "/issues/reorder": + s.handleIssues(w, r, body) case "/refs/update": s.handleRefsUpdate(w, r, body) default: @@ -576,6 +590,243 @@ func (s *localBrokerServer) handleObjectsList(w http.ResponseWriter, r *http.Req localBrokerJSON(w, 200, brokerObjectResponse{Paths: paths}) } +func (s *localBrokerServer) handleIssues(w http.ResponseWriter, r *http.Request, body []byte) { + var req brokerIssueRequest + if !localBrokerDecode(w, body, &req) { + return + } + state, err := s.loadRepoForRequest(req.Repo) + if err != nil { + localBrokerJSON(w, http.StatusNotFound, map[string]string{"error": "repository not found"}) + return + } + operation := "write" + if r.URL.Path == "/issues/list" || r.URL.Path == "/issues/view" { + operation = "read" + } + key, ok := s.signedRepoKey(r, body, state, operation) + if !ok { + localBrokerJSON(w, http.StatusForbidden, map[string]string{"error": operation + " SSH signature required"}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + data, status, err := s.localIssueEndpoint(r.URL.Path, req, state, firstNonEmpty(key.User, "owner")) + if err != nil { + localBrokerJSON(w, status, map[string]string{"error": err.Error()}) + return + } + if len(data) == 0 { + localBrokerJSON(w, status, map[string]bool{"ok": true}) + return + } + w.Header().Set("content-type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) +} + +func (s *localBrokerServer) localIssueEndpoint(path string, req brokerIssueRequest, state localBrokerRepoState, user string) ([]byte, int, error) { + store, err := s.loadIssueStore(state.Repo) + if err != nil { + return nil, 500, err + } + user = firstNonEmpty(strings.TrimSpace(user), "owner") + switch path { + case "/issues/list": + issues := make([]brokerIssue, 0, len(store.Issues)) + for _, issue := range store.Issues { + if req.Type != "" && issue.Type != req.Type { + continue + } + if issue.Archived && !req.IncludeArchived { + continue + } + issues = append(issues, issue) + } + sortBoardStories(issues) + return mustJSON(map[string]any{"issues": issues}), 200, nil + case "/issues/view": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + return mustJSON(map[string]any{"issue": *issue}), 200, nil + case "/issues/create": + title := strings.TrimSpace(req.Title) + body := strings.TrimSpace(req.Body) + if title == "" { + title = storySummary(body) + } + if title == "" { + return nil, http.StatusBadRequest, errors.New("issue title is required") + } + now := time.Now().UTC().Format(time.RFC3339) + if store.NextID <= 0 { + store.NextID = localBrokerNextIssueID(store.Issues) + } + issueType := firstNonEmpty(strings.TrimSpace(req.Type), "issue") + lane := strings.TrimSpace(req.Lane) + position := 0.0 + if issueType == "story" { + lane = normalizeKanbanLane(lane) + position = localBrokerNextStoryPosition(store.Issues, lane) + } + issue := brokerIssue{ + ID: store.NextID, + Type: issueType, + Title: title, + Body: body, + Status: "open", + Lane: lane, + Assignee: strings.TrimSpace(req.Assignee), + Position: position, + Author: user, + CreatedAt: now, + UpdatedAt: now, + History: []brokerIssueEvent{{User: user, Action: "created", At: now}}, + } + store.NextID++ + store.Issues = append(store.Issues, issue) + if issueType == "story" { + localBrokerNormalizeLaneOrder(&store, lane, 0) + if created, err := localBrokerFindIssue(&store, issue.ID); err == nil { + issue = *created + } + } + if err := s.saveIssueStore(state.Repo, store); err != nil { + return nil, 500, err + } + return mustJSON(map[string]any{"issue": issue}), 200, nil + case "/issues/update": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + if req.Type != "" { + issue.Type = req.Type + } + if strings.TrimSpace(req.Title) != "" { + issue.Title = strings.TrimSpace(req.Title) + } + if req.Body != "" { + issue.Body = strings.TrimSpace(req.Body) + } + if issue.Title == "" && issue.Type == "story" { + issue.Title = storySummary(issue.Body) + } + localBrokerIssueEvent(issue, user, "edited", "", "", "") + case "/issues/comment": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + comment := strings.TrimSpace(req.Comment) + if comment == "" { + return nil, http.StatusBadRequest, errors.New("comment is required") + } + now := time.Now().UTC().Format(time.RFC3339) + issue.Comments = append(issue.Comments, brokerIssueReply{User: user, Body: comment, At: now}) + localBrokerIssueEvent(issue, user, "commented", "", "", "") + case "/issues/close", "/issues/reopen": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + status := "closed" + action := "closed" + if path == "/issues/reopen" { + status = "open" + action = "reopened" + } + if issue.Status != status { + issue.Status = status + localBrokerIssueEvent(issue, user, action, "", "", "") + } + case "/issues/move", "/issues/reorder": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + lane, err := parseKanbanLane(firstNonEmpty(req.Lane, issue.Lane)) + if err != nil { + return nil, http.StatusBadRequest, err + } + from := normalizeKanbanLane(issue.Lane) + order := localBrokerStoryOrderFromRequest(req.Order) + if path == "/issues/reorder" { + localBrokerApplyStoryOrder(&store, issue.ID, lane, req.AfterID, order) + if from != lane { + localBrokerNormalizeLaneOrder(&store, from, issue.ID) + } + } else if req.AfterID != nil { + localBrokerApplyStoryOrder(&store, issue.ID, lane, req.AfterID, 0) + if from != lane { + localBrokerNormalizeLaneOrder(&store, from, issue.ID) + } + } else { + issue.Lane = lane + issue.Position = localBrokerNextStoryPosition(store.Issues, lane) + if from != lane { + localBrokerNormalizeLaneOrder(&store, from, issue.ID) + } + localBrokerNormalizeLaneOrder(&store, lane, 0) + } + issue, _ = localBrokerFindIssue(&store, req.ID) + action := "moved" + if path == "/issues/reorder" { + action = "reordered" + } + localBrokerIssueEvent(issue, user, action, from, lane, fmt.Sprintf("%.6f", issue.Position)) + case "/issues/take": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + fromAssignee := issue.Assignee + fromLane := normalizeKanbanLane(issue.Lane) + issue.Assignee = user + lane := normalizeKanbanLane(issue.Lane) + if lane == "backlog" { + lane = "doing" + localBrokerApplyStoryOrder(&store, issue.ID, lane, nil, 0) + localBrokerNormalizeLaneOrder(&store, fromLane, issue.ID) + issue, _ = localBrokerFindIssue(&store, req.ID) + } else if issue.Position == 0 { + localBrokerNormalizeLaneOrder(&store, lane, 0) + issue, _ = localBrokerFindIssue(&store, req.ID) + } + localBrokerIssueEvent(issue, user, "assigned", fromAssignee, user, "") + case "/issues/assign": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + from := issue.Assignee + to := strings.TrimSpace(req.Assignee) + issue.Assignee = to + localBrokerIssueEvent(issue, user, "assigned", from, to, "") + case "/issues/archive": + issue, err := localBrokerFindIssue(&store, req.ID) + if err != nil { + return nil, http.StatusNotFound, err + } + issue.Archived = req.Archived + action := "unarchived" + if req.Archived { + action = "archived" + } + localBrokerIssueEvent(issue, user, action, "", "", "") + default: + return mustJSON(map[string]string{"error": "unknown broker endpoint"}), http.StatusNotFound, nil + } + if path != "/issues/list" && path != "/issues/view" && path != "/issues/create" { + if err := s.saveIssueStore(state.Repo, store); err != nil { + return nil, 500, err + } + } + return mustJSON(map[string]bool{"ok": true}), 200, nil +} + func (s *localBrokerServer) handleRefsUpdate(w http.ResponseWriter, r *http.Request, body []byte) { var req brokerRefUpdateRequest if !localBrokerDecode(w, body, &req) { @@ -813,6 +1064,34 @@ func (s *localBrokerServer) saveRepo(state localBrokerRepoState) error { return s.upsertRepoIndex(state.Repo) } +func (s *localBrokerServer) loadIssueStore(repo brokerRepo) (localBrokerIssueStore, error) { + var store localBrokerIssueStore + data, err := s.readObject(repo, localBrokerIssuesPath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + return localBrokerIssueStore{NextID: 1}, nil + } + return store, err + } + if len(bytes.TrimSpace(data)) == 0 { + return localBrokerIssueStore{NextID: 1}, nil + } + if err := json.Unmarshal(data, &store); err != nil { + return store, err + } + if store.NextID <= 0 { + store.NextID = localBrokerNextIssueID(store.Issues) + } + return store, nil +} + +func (s *localBrokerServer) saveIssueStore(repo brokerRepo, store localBrokerIssueStore) error { + if store.NextID <= 0 { + store.NextID = localBrokerNextIssueID(store.Issues) + } + return s.writeObject(repo, localBrokerIssuesPath(), mustJSON(store)) +} + func (s *localBrokerServer) repoIndexPath() string { return filepath.Join(s.root, "repos.json") } @@ -1282,6 +1561,125 @@ func localBrokerRefRecordPath(ref string) string { return ".bucketgit/broker-state/v1/refs/" + base64.RawURLEncoding.EncodeToString([]byte(ref)) + ".json" } +func localBrokerIssuesPath() string { + return ".bucketgit/broker-state/v1/issues.json" +} + +func localBrokerFindIssue(store *localBrokerIssueStore, id int) (*brokerIssue, error) { + if id <= 0 { + return nil, errors.New("issue id is required") + } + for i := range store.Issues { + if store.Issues[i].ID == id { + return &store.Issues[i], nil + } + } + return nil, errors.New("issue not found") +} + +func localBrokerNextIssueID(issues []brokerIssue) int { + next := 1 + for _, issue := range issues { + if issue.ID >= next { + next = issue.ID + 1 + } + } + return next +} + +func localBrokerNextStoryPosition(issues []brokerIssue, lane string) float64 { + count := 0 + for _, issue := range issues { + if issue.Type == "story" && !issue.Archived && normalizeKanbanLane(issue.Lane) == lane { + count++ + } + } + return float64(count + 1) +} + +func localBrokerStoryOrderFromRequest(order int) int { + if order > 0 { + return order + } + return 0 +} + +func localBrokerApplyStoryOrder(store *localBrokerIssueStore, issueID int, lane string, afterID *int, order int) { + target, err := localBrokerFindIssue(store, issueID) + if err != nil { + return + } + target.Lane = lane + stories := localBrokerLaneStories(store, lane, issueID) + index := len(stories) + if order > 0 { + index = order - 1 + if index < 0 { + index = 0 + } + if index > len(stories) { + index = len(stories) + } + } else if afterID != nil { + index = 0 + if *afterID > 0 { + index = len(stories) + for i, story := range stories { + if story.ID == *afterID { + index = i + 1 + break + } + } + } + } + stories = append(stories, nil) + copy(stories[index+1:], stories[index:]) + stories[index] = target + for i, story := range stories { + story.Lane = lane + story.Position = float64(i + 1) + } +} + +func localBrokerNormalizeLaneOrder(store *localBrokerIssueStore, lane string, excludeID int) { + stories := localBrokerLaneStories(store, lane, excludeID) + for i, story := range stories { + story.Position = float64(i + 1) + } +} + +func localBrokerLaneStories(store *localBrokerIssueStore, lane string, excludeID int) []*brokerIssue { + stories := []*brokerIssue{} + for i := range store.Issues { + issue := &store.Issues[i] + if issue.ID == excludeID || issue.Type != "story" || issue.Archived || normalizeKanbanLane(issue.Lane) != lane { + continue + } + stories = append(stories, issue) + } + sort.SliceStable(stories, func(i, j int) bool { + left := stories[i].Position + right := stories[j].Position + if left != right { + if left == 0 { + return false + } + if right == 0 { + return true + } + return left < right + } + return stories[i].ID < stories[j].ID + }) + return stories +} + +func localBrokerIssueEvent(issue *brokerIssue, user, action, from, to, position string) { + now := time.Now().UTC().Format(time.RFC3339) + issue.UpdatedAt = now + issue.History = append(issue.History, brokerIssueEvent{User: user, Action: action, From: from, To: to, Position: position, At: now}) +} + func (s *localBrokerServer) localRefHash(repo brokerRepo, ref, fallback string) string { var record struct { Hash string `json:"hash"` diff --git a/main.go b/main.go index f063d86..8831fd7 100644 --- a/main.go +++ b/main.go @@ -630,6 +630,15 @@ func mergeConfig(primary, fallback config) config { if primary.region == "" { primary.region = fallback.region } + if primary.storageProvider == "" { + primary.storageProvider = fallback.storageProvider + } + if primary.storageProfile == "" { + primary.storageProfile = fallback.storageProfile + } + if primary.storageRegion == "" { + primary.storageRegion = fallback.storageRegion + } if primary.branch == "" || primary.branch == defaultBranch { primary.branch = fallback.branch } diff --git a/main_test.go b/main_test.go index 093bd48..4883b9e 100644 --- a/main_test.go +++ b/main_test.go @@ -1351,6 +1351,120 @@ func TestLocalBrokerRelativeFileRepoUsesBrokerRoot(t *testing.T) { } } +func TestLocalBrokerIssuesSupportBoardOperations(t *testing.T) { + root := t.TempDir() + server := &localBrokerServer{root: root, objectRoot: filepath.Join(root, "objects")} + repo := server.physicalRepo(brokerRepo{Provider: "file", Bucket: "file://demo.git", Prefix: "demo.git", Logical: "demo.git"}) + state := localBrokerRepoState{ + Repo: repo, + Keys: []brokerKey{{User: "owner", Role: "owner", PublicKey: "test-key"}}, + Refs: map[string]string{}, + } + if err := server.saveRepo(state); err != nil { + t.Fatal(err) + } + out, status, err := server.localPost("/issues/list", brokerIssueRequest{Repo: repo, Type: "story"}) + if err != nil || status != 200 { + t.Fatalf("initial list status=%d err=%v body=%s", status, err, out) + } + var listResp struct { + Issues []brokerIssue `json:"issues"` + } + if err := json.Unmarshal(out, &listResp); err != nil { + t.Fatal(err) + } + if len(listResp.Issues) != 0 { + t.Fatalf("initial issues = %#v", listResp.Issues) + } + + out, status, err = server.localPost("/issues/create", brokerIssueRequest{Repo: repo, Type: "story", Title: "Local board works", Body: "Local board works", Lane: "backlog"}) + if err != nil || status != 200 { + t.Fatalf("create status=%d err=%v body=%s", status, err, out) + } + var createResp struct { + Issue brokerIssue `json:"issue"` + } + if err := json.Unmarshal(out, &createResp); err != nil { + t.Fatal(err) + } + if createResp.Issue.ID != 1 || createResp.Issue.Lane != "backlog" || createResp.Issue.Position == 0 { + t.Fatalf("created issue = %#v", createResp.Issue) + } + if _, status, err = server.localPost("/issues/move", brokerIssueRequest{Repo: repo, ID: createResp.Issue.ID, Lane: "doing"}); err != nil || status != 200 { + t.Fatalf("move status=%d err=%v", status, err) + } + if _, status, err = server.localPost("/issues/assign", brokerIssueRequest{Repo: repo, ID: createResp.Issue.ID, Assignee: "owner"}); err != nil || status != 200 { + t.Fatalf("assign status=%d err=%v", status, err) + } + if _, status, err = server.localPost("/issues/archive", brokerIssueRequest{Repo: repo, ID: createResp.Issue.ID, Archived: true}); err != nil || status != 200 { + t.Fatalf("archive status=%d err=%v", status, err) + } + + out, status, err = server.localPost("/issues/list", brokerIssueRequest{Repo: repo, Type: "story"}) + if err != nil || status != 200 { + t.Fatalf("list status=%d err=%v body=%s", status, err, out) + } + if err := json.Unmarshal(out, &listResp); err != nil { + t.Fatal(err) + } + if len(listResp.Issues) != 0 { + t.Fatalf("non-archived list = %#v", listResp.Issues) + } + out, status, err = server.localPost("/issues/list", brokerIssueRequest{Repo: repo, Type: "story", IncludeArchived: true}) + if err != nil || status != 200 { + t.Fatalf("archived list status=%d err=%v body=%s", status, err, out) + } + if err := json.Unmarshal(out, &listResp); err != nil { + t.Fatal(err) + } + if len(listResp.Issues) != 1 || !listResp.Issues[0].Archived || listResp.Issues[0].Lane != "doing" || listResp.Issues[0].Assignee != "owner" { + t.Fatalf("archived list = %#v", listResp.Issues) + } + if _, err := os.Stat(filepath.Join(root, "objects", "demo.git", localBrokerIssuesPath())); err != nil { + t.Fatalf("issues were not persisted with broker state: %v", err) + } +} + +func TestLocalBrokerReorderNormalizesDenseStoryOrder(t *testing.T) { + root := t.TempDir() + server := &localBrokerServer{root: root, objectRoot: filepath.Join(root, "objects")} + repo := server.physicalRepo(brokerRepo{Provider: "file", Bucket: "file://ordered.git", Prefix: "ordered.git", Logical: "ordered.git"}) + state := localBrokerRepoState{ + Repo: repo, + Keys: []brokerKey{{User: "owner", Role: "owner", PublicKey: "test-key"}}, + Refs: map[string]string{}, + } + if err := server.saveRepo(state); err != nil { + t.Fatal(err) + } + store := localBrokerIssueStore{NextID: 5, Issues: []brokerIssue{ + {ID: 1, Type: "story", Title: "one", Lane: "doing", Position: 10}, + {ID: 2, Type: "story", Title: "two", Lane: "doing", Position: 20}, + {ID: 3, Type: "story", Title: "three", Lane: "doing", Position: 30}, + {ID: 4, Type: "story", Title: "four", Lane: "doing", Position: 40}, + }} + if err := server.saveIssueStore(repo, store); err != nil { + t.Fatal(err) + } + out, status, err := server.localPost("/issues/reorder", brokerIssueRequest{Repo: repo, ID: 4, Lane: "doing", Order: 3}) + if err != nil || status != 200 { + t.Fatalf("reorder status=%d err=%v body=%s", status, err, out) + } + store, err = server.loadIssueStore(repo) + if err != nil { + t.Fatal(err) + } + sortBoardStories(store.Issues) + got := []string{} + for _, issue := range store.Issues { + got = append(got, fmt.Sprintf("%d:%.0f", issue.ID, issue.Position)) + } + want := []string{"1:1", "2:2", "4:3", "3:4"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("order = %v, want %v", got, want) + } +} + func TestEnsureCloneDestinationAvailableRejectsNonEmptyDirectory(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("content\n"), 0o644); err != nil { @@ -1842,6 +1956,18 @@ func TestMergeConfigUsesRepoRegion(t *testing.T) { } } +func TestMergeConfigUsesLocalBrokerStorageTarget(t *testing.T) { + local := config{storageProvider: "s3", storageProfile: "default", storageRegion: "eu-west-1"} + merged := mergeConfig(config{}, local) + if merged.storageProvider != "s3" || merged.storageProfile != "default" || merged.storageRegion != "eu-west-1" { + t.Fatalf("merged storage = %#v", merged) + } + merged = mergeConfig(config{storageProvider: "gcs", storageProfile: "prod", storageRegion: "europe-west1"}, local) + if merged.storageProvider != "gcs" || merged.storageProfile != "prod" || merged.storageRegion != "europe-west1" { + t.Fatalf("explicit storage = %#v", merged) + } +} + func TestDefaultAWSRegion(t *testing.T) { t.Setenv("AWS_REGION", "") t.Setenv("AWS_DEFAULT_REGION", "") diff --git a/web.go b/web.go index 20837ca..34bc228 100644 --- a/web.go +++ b/web.go @@ -207,6 +207,7 @@ type brokerIssueRequest struct { Assignee string `json:"assignee,omitempty"` Comment string `json:"comment,omitempty"` AfterID *int `json:"after_id,omitempty"` + Order int `json:"order,omitempty"` Archived bool `json:"archived,omitempty"` IncludeArchived bool `json:"include_archived,omitempty"` }