From b53065c32186710f0098fde354bbd3f5030077ac Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Sat, 30 May 2026 08:39:17 +0200 Subject: [PATCH 1/3] Updated min go version to build from source in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b273e6..b2c2bd5 100644 --- a/README.md +++ b/README.md @@ -579,7 +579,7 @@ Runtime requirements depend on the command: - `gcloud` for GCP setup and profile creation. - AWS config/credentials files and optionally the AWS CLI for AWS setup/profile creation. -- Go 1.22 or newer to build from source. +- Go 1.24 or newer to build from source. ## Unsupported Commands From afd9a3222696436bb4d847f74a9789aa6850303a Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Mon, 1 Jun 2026 09:47:49 +0200 Subject: [PATCH 2/3] Add board archiving and sync origin tracking refs --- CHANGELOG.md | 17 ++++ broker/aws/template.yaml | 114 ++++++++++++++++++++++++- broker/gcp/index.js | 122 ++++++++++++++++++++++++++- broker_commands.go | 124 ++++++++++++++++++++++++++- broker_commands_test.go | 80 ++++++++++++++++++ main_test.go | 28 +++++- native_git.go | 42 +++++---- web.go | 178 +++++++++++++++++++++++++++++++++++---- www/app.css | 40 +++++++++ www/app.js | 131 +++++++++++++++++++++++++--- 10 files changed, 823 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094831b..16c4e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to `bgit` are documented in this file. This project follows semantic versioning. +## 1.3.2 + +Added + +- Added Task board story editing, archiving, archived-story listing, activity + history, and persistent ordering/reordering support across AWS and GCP + brokers. +- Added `bgit board edit`, `bgit board assign`, `bgit board archive`, and + `bgit board unarchive` commands. + +Fixed + +- `bgit fetch` and `bgit push` now keep matching `origin/*` remote-tracking refs + in sync with `bucketgit/*`, so native `git status` no longer reports stale + ahead/behind state after bgit operations. +- `bgit web` now counts only open pull requests in the Pull requests tab badge. + ## 1.3.1 Fixed diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index 73dc09a..78697a2 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -1517,6 +1517,55 @@ Resources: })); return (out.Items || []).map((item) => JSON.parse(item.data.S || "{}")); } + function issueHistory(issue, user, action, fields = {}) { + issue.history = Array.isArray(issue.history) ? issue.history : []; + issue.history.push({user: user || "anonymous", action, at: new Date().toISOString(), ...fields}); + if (issue.history.length > 100) issue.history = issue.history.slice(-100); + } + function storyPosition(issue) { + const value = Number(issue && issue.position || 0); + return Number.isFinite(value) && value > 0 ? value : 0; + } + function sortStoriesForLane(stories) { + return stories.sort((a, b) => { + const ap = storyPosition(a); + const bp = storyPosition(b); + if (ap && bp && ap !== bp) return ap - bp; + if (ap && !bp) return -1; + if (!ap && bp) return 1; + return Number(a.id || 0) - Number(b.id || 0); + }); + } + async function nextStoryPosition(entry, lane, excludeID = 0) { + const stories = (await listIssues(entry)).filter((issue) => + issue.type === "story" && + !issue.archived && + 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; + } + 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; + } + 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); + } function nextCIID(data) { data.next_ci_id = Number(data.next_ci_id || 1); return data.next_ci_id++; @@ -2418,7 +2467,10 @@ Resources: const entry = await loadRepo(body.repo); await requireOperation(event, entry, "read"); if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); - const issues = await listIssues(entry); + let issues = await listIssues(entry); + const type = String(body.type || "").trim().toLowerCase(); + if (type) issues = issues.filter((issue) => String(issue.type || "").toLowerCase() === type); + if (type === "story" && !body.include_archived) issues = issues.filter((issue) => !issue.archived); return response(200, {issues}); } if (path === "/issues/view" && method === "POST") { @@ -2437,7 +2489,9 @@ Resources: const issueBody = String(body.body || "").trim(); const title = String(body.title || "").trim() || (story ? issueBody.replace(/\s+/g, " ").slice(0, 80) : ""); if (!title) throw new Error(story ? "story is required" : "issue title is required"); - const issue = {id: nextIssueID(entry.data), type: story ? "story" : "issue", title, body: issueBody, status: "open", lane: story ? normalizeBoardLane(body.lane) : "", assignee: "", author: key.user || "anonymous", comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + const lane = story ? normalizeBoardLane(body.lane) : ""; + 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); return response(200, {ok: true, issue}); @@ -2452,6 +2506,7 @@ Resources: if (!comment) throw new Error("comment is required"); issue.comments = issue.comments || []; issue.comments.push({user: key.user || "anonymous", body: comment, at: new Date().toISOString()}); + if (issue.type === "story") issueHistory(issue, key.user, "commented"); issue.updated_at = new Date().toISOString(); await saveIssue(entry, issue); return response(200, {ok: true, issue}); @@ -2461,16 +2516,68 @@ Resources: await requireBoardMutation(event, entry); return response(200, {users: await boardAssignableUsers(entry)}); } + if (path === "/issues/update" && method === "POST") { + const entry = await loadRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const key = await requireBoardMutation(event, entry); + const issue = await loadIssue(entry, body.id); + if (!issue || issue.type !== "story") throw Object.assign(new Error("story not found"), {statusCode: 404}); + const issueBody = String(body.body || "").trim(); + const title = String(body.title || "").trim() || issueBody.replace(/\s+/g, " ").slice(0, 80); + if (!title) throw new Error("story is required"); + issue.title = title; + issue.body = issueBody; + issue.updated_at = new Date().toISOString(); + issueHistory(issue, key.user, "edited"); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/archive" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = await requireBoardMutation(event, entry); + const issue = await loadIssue(entry, body.id); + if (!issue || issue.type !== "story") throw Object.assign(new Error("story not found"), {statusCode: 404}); + const archived = !!body.archived; + issue.archived = archived; + issue.updated_at = new Date().toISOString(); + issueHistory(issue, key.user, archived ? "archived" : "unarchived"); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/reorder" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = await requireBoardMutation(event, entry); + const issue = await loadIssue(entry, body.id); + 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(); + issueHistory(issue, key.user, "reordered", {from: fromLane, to: lane, position: String(issue.position)}); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } if (path === "/issues/move" || path === "/issues/take" || path === "/issues/assign") { const entry = await loadRepo(body.repo); const key = await requireBoardMutation(event, entry); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); if (issue.type !== "story") throw Object.assign(new Error("story not found"), {statusCode: 404}); - if (path === "/issues/move") issue.lane = normalizeBoardLane(body.lane); + const fromLane = normalizeBoardLane(issue.lane); + 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); + issueHistory(issue, key.user, "moved", {from: fromLane, to: lane}); + } 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); + issueHistory(issue, key.user, "assigned", {from: fromAssignee, to: issue.assignee || ""}); } else { const assignee = String(body.assignee || "").trim(); if (assignee) { @@ -2481,6 +2588,7 @@ Resources: } else { issue.assignee = ""; } + issueHistory(issue, key.user, "assigned", {from: fromAssignee, to: issue.assignee || ""}); } issue.updated_at = new Date().toISOString(); await saveIssue(entry, issue); diff --git a/broker/gcp/index.js b/broker/gcp/index.js index fdc64c1..57cae23 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -1078,6 +1078,60 @@ async function listIssues(entry) { return snap.docs.map((doc) => doc.data() || {}); } +function issueHistory(issue, user, action, fields = {}) { + issue.history = Array.isArray(issue.history) ? issue.history : []; + issue.history.push({user: user || 'anonymous', action, at: new Date().toISOString(), ...fields}); + if (issue.history.length > 100) issue.history = issue.history.slice(-100); +} + +function storyPosition(issue) { + const value = Number(issue && issue.position || 0); + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function sortStoriesForLane(stories) { + return stories.sort((a, b) => { + const ap = storyPosition(a); + const bp = storyPosition(b); + if (ap && bp && ap !== bp) return ap - bp; + if (ap && !bp) return -1; + if (!ap && bp) return 1; + return Number(a.id || 0) - Number(b.id || 0); + }); +} + +async function nextStoryPosition(entry, lane, excludeID = 0) { + const stories = (await listIssues(entry)).filter((issue) => + issue.type === 'story' && + !issue.archived && + 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; +} + +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; + } + 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); +} + function nextCIID(data) { data.next_ci_id = Number(data.next_ci_id || 1); return data.next_ci_id++; @@ -2157,7 +2211,10 @@ exports.broker = async (req, res) => { const entry = await ensureRepo(body.repo); await requireRead(req, entry); if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); - const issues = await listIssues(entry); + let issues = await listIssues(entry); + const type = String(body.type || '').trim().toLowerCase(); + if (type) issues = issues.filter((issue) => String(issue.type || '').toLowerCase() === type); + if (type === 'story' && !body.include_archived) issues = issues.filter((issue) => !issue.archived); res.status(200).send(JSON.stringify({issues})); return; } @@ -2178,7 +2235,9 @@ exports.broker = async (req, res) => { const issueBody = String(body.body || '').trim(); const title = String(body.title || '').trim() || (story ? issueBody.replace(/\s+/g, ' ').slice(0, 80) : ''); if (!title) throw new Error(story ? 'story is required' : 'issue title is required'); - const issue = {id: nextIssueID(entry.data), type: story ? 'story' : 'issue', title, body: issueBody, status: 'open', lane: story ? normalizeBoardLane(body.lane) : '', assignee: '', author: key.user || 'anonymous', comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + const lane = story ? normalizeBoardLane(body.lane) : ''; + 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); res.status(200).send(JSON.stringify({ok: true, issue})); @@ -2194,6 +2253,7 @@ exports.broker = async (req, res) => { if (!comment) throw new Error('comment is required'); issue.comments = issue.comments || []; issue.comments.push({user: key.user || 'anonymous', body: comment, at: new Date().toISOString()}); + if (issue.type === 'story') issueHistory(issue, key.user, 'commented'); issue.updated_at = new Date().toISOString(); await saveIssue(entry, issue); res.status(200).send(JSON.stringify({ok: true, issue})); @@ -2205,16 +2265,71 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({users: await boardAssignableUsers(entry)})); return; } + if (req.path === '/issues/update' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const key = await requireBoardMutation(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue || issue.type !== 'story') throw Object.assign(new Error('story not found'), {status: 404}); + const issueBody = String(body.body || '').trim(); + const title = String(body.title || '').trim() || issueBody.replace(/\s+/g, ' ').slice(0, 80); + if (!title) throw new Error('story is required'); + issue.title = title; + issue.body = issueBody; + issue.updated_at = new Date().toISOString(); + issueHistory(issue, key.user, 'edited'); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/archive' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = await requireBoardMutation(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue || issue.type !== 'story') throw Object.assign(new Error('story not found'), {status: 404}); + const archived = !!body.archived; + issue.archived = archived; + issue.updated_at = new Date().toISOString(); + issueHistory(issue, key.user, archived ? 'archived' : 'unarchived'); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/reorder' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = await requireBoardMutation(req, entry); + const issue = await loadIssue(entry, body.id); + 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(); + issueHistory(issue, key.user, 'reordered', {from: fromLane, to: lane, position: String(issue.position)}); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } if (req.path === '/issues/move' || req.path === '/issues/take' || req.path === '/issues/assign') { const entry = await ensureRepo(body.repo); const key = await requireBoardMutation(req, entry); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); if (issue.type !== 'story') throw Object.assign(new Error('story not found'), {status: 404}); - if (req.path === '/issues/move') issue.lane = normalizeBoardLane(body.lane); + const fromLane = normalizeBoardLane(issue.lane); + 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); + issueHistory(issue, key.user, 'moved', {from: fromLane, to: lane}); + } 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); + issueHistory(issue, key.user, 'assigned', {from: fromAssignee, to: issue.assignee || ''}); } else { const assignee = String(body.assignee || '').trim(); if (assignee) { @@ -2225,6 +2340,7 @@ exports.broker = async (req, res) => { } else { issue.assignee = ''; } + issueHistory(issue, key.user, 'assigned', {from: fromAssignee, to: issue.assignee || ''}); } issue.updated_at = new Date().toISOString(); await saveIssue(entry, issue); diff --git a/broker_commands.go b/broker_commands.go index cfca424..79e0efe 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|move|take|comment [args]") + return errors.New("usage: bgit board list|create|edit|move|take|assign|archive|unarchive|comment [args]") } cfg, err := configForBrokerCommand(config{}) if err != nil { @@ -2879,16 +2879,40 @@ func boardCommand(args []string, stdin io.Reader, stdout io.Writer) error { monogram := repoMonogram(cfg.logicalRepo) switch args[0] { case "list": + includeArchived := false + for _, arg := range args[1:] { + switch arg { + case "--archived", "--all": + includeArchived = true + default: + return fmt.Errorf("unsupported board list option %s", arg) + } + } var resp struct { Issues []brokerIssue `json:"issues"` } - if err := brokerPost(cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(cfg), Type: "story"}, &resp); err != nil { + if err := brokerPost(cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(cfg), Type: "story", IncludeArchived: includeArchived}, &resp); err != nil { return err } + sortBoardStories(resp.Issues) + if includeArchived { + fmt.Fprintf(stdout, "archived\n") + for _, issue := range resp.Issues { + if issue.Type != "story" || !issue.Archived { + continue + } + assignee := "" + if issue.Assignee != "" { + assignee = "\t@" + issue.Assignee + } + fmt.Fprintf(stdout, " %s\t%s%s\n", storyDisplayID(monogram, issue.ID), issue.Title, assignee) + } + return nil + } for _, lane := range kanbanLanes() { fmt.Fprintf(stdout, "%s\n", lane) for _, issue := range resp.Issues { - if issue.Type != "story" || normalizeKanbanLane(issue.Lane) != lane { + if issue.Type != "story" || issue.Archived || normalizeKanbanLane(issue.Lane) != lane { continue } assignee := "" @@ -2932,6 +2956,23 @@ func boardCommand(args []string, stdin io.Reader, stdout io.Writer) error { } fmt.Fprintf(stdout, "created story %s\n", storyDisplayID(monogram, resp.Issue.ID)) return nil + case "edit": + if len(args) < 3 { + return errors.New("usage: bgit board edit STORY_ID STORY") + } + id, err := parseBoardStoryIDArg(args[:2], monogram) + if err != nil { + return err + } + story := strings.Join(args[2:], " ") + if strings.TrimSpace(story) == "" { + return errors.New("story is required") + } + if err := brokerPost(cfg.brokerURL, "/issues/update", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Type: "story", Title: storySummary(story), Body: story}, nil); err != nil { + return boardUpgradeError(err) + } + fmt.Fprintf(stdout, "edited story %s\n", storyDisplayID(monogram, id)) + return nil case "move": if len(args) != 3 { return errors.New("usage: bgit board move STORY_ID backlog|ready|doing|review|done") @@ -2959,6 +3000,42 @@ func boardCommand(args []string, stdin io.Reader, stdout io.Writer) error { } fmt.Fprintf(stdout, "took story %s\n", storyDisplayID(monogram, id)) return nil + case "assign": + if len(args) != 3 { + return errors.New("usage: bgit board assign STORY_ID USER|unassigned") + } + id, err := parseBoardStoryIDArg(args[:2], monogram) + if err != nil { + return err + } + assignee := args[2] + if strings.EqualFold(assignee, "unassigned") || strings.EqualFold(assignee, "none") || assignee == "-" { + assignee = "" + } + if err := brokerPost(cfg.brokerURL, "/issues/assign", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Assignee: assignee}, nil); err != nil { + return err + } + if assignee == "" { + fmt.Fprintf(stdout, "unassigned story %s\n", storyDisplayID(monogram, id)) + } else { + fmt.Fprintf(stdout, "assigned story %s to %s\n", storyDisplayID(monogram, id), assignee) + } + return nil + case "archive", "unarchive": + id, err := parseBoardStoryIDArg(args, monogram) + if err != nil { + return err + } + archived := args[0] == "archive" + if err := brokerPost(cfg.brokerURL, "/issues/archive", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Archived: archived}, nil); err != nil { + return boardUpgradeError(err) + } + if archived { + fmt.Fprintf(stdout, "archived story %s\n", storyDisplayID(monogram, id)) + } else { + fmt.Fprintf(stdout, "unarchived story %s\n", storyDisplayID(monogram, id)) + } + return nil case "comment": if len(args) < 3 { return errors.New("usage: bgit board comment STORY_ID COMMENT") @@ -2985,6 +3062,47 @@ func parseBoardStoryIDArg(args []string, monogram string) (int, error) { return parseStoryDisplayID(args[1], monogram) } +func boardUpgradeError(err error) error { + if err == nil { + return nil + } + if strings.Contains(err.Error(), "unknown broker endpoint") { + return fmt.Errorf("%w\nbroker needs to be upgraded for story edit/archive/order support; run `bgit admin broker upgrade`", err) + } + return err +} + +func sortBoardStories(stories []brokerIssue) { + sort.SliceStable(stories, func(i, j int) bool { + leftLane := normalizeKanbanLane(stories[i].Lane) + rightLane := normalizeKanbanLane(stories[j].Lane) + if leftLane != rightLane { + return laneSortIndex(leftLane) < laneSortIndex(rightLane) + } + leftPosition := stories[i].Position + rightPosition := stories[j].Position + if leftPosition != rightPosition { + if leftPosition == 0 { + return false + } + if rightPosition == 0 { + return true + } + return leftPosition < rightPosition + } + return stories[i].ID < stories[j].ID + }) +} + +func laneSortIndex(lane string) int { + for i, candidate := range kanbanLanes() { + if candidate == lane { + return i + } + } + return len(kanbanLanes()) +} + func parseIssueIDArg(args []string) (int, error) { if len(args) != 2 { return 0, errors.New("issue id is required") diff --git a/broker_commands_test.go b/broker_commands_test.go index 6ad01cf..df10a28 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -382,6 +382,86 @@ func TestBoardCommandListsStoriesByLane(t *testing.T) { } } +func TestBoardCommandEditsAndArchivesStories(t *testing.T) { + var updateReq brokerIssueRequest + var archiveReq brokerIssueRequest + target, server, requests := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/issues/update": + if err := json.NewDecoder(r.Body).Decode(&updateReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/issues/archive": + if err := json.NewDecoder(r.Body).Decode(&archiveReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }) + 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{"edit", "AP-7", "As", "a", "user,", "I", "want", "edited", "story."}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if updateReq.ID != 7 || updateReq.Type != "story" || updateReq.Body != "As a user, I want edited story." || updateReq.Title != "As a user, I want edited story." { + t.Fatalf("update req = %#v", updateReq) + } + stdout.Reset() + if err := boardCommand([]string{"archive", "AP-7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if archiveReq.ID != 7 || !archiveReq.Archived { + t.Fatalf("archive req = %#v", archiveReq) + } + if got := strings.Join(*requests, ","); got != "/issues/update,/issues/archive" { + t.Fatalf("requests = %s", got) + } +} + +func TestBoardCommandListsArchivedStoriesSeparately(t *testing.T) { + var listReq brokerIssueRequest + target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issues/list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&listReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"issues":[{"id":1,"type":"story","title":"Active","lane":"backlog"},{"id":2,"type":"story","title":"Archived","lane":"done","archived":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{"list", "--archived"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if !listReq.IncludeArchived || listReq.Type != "story" { + t.Fatalf("list req = %#v", listReq) + } + out := stdout.String() + if !strings.Contains(out, "archived\n") || !strings.Contains(out, "AP-2\tArchived") || strings.Contains(out, "Active") { + t.Fatalf("stdout = %q", out) + } +} + func TestSortedBoardAssigneesDeduplicatesCaseInsensitive(t *testing.T) { got := sortedBoardAssignees([]string{"zoe", "Ada", "ada", "", "mike"}) want := []string{"Ada", "mike", "zoe"} diff --git a/main_test.go b/main_test.go index 2a4baae..093bd48 100644 --- a/main_test.go +++ b/main_test.go @@ -3375,6 +3375,15 @@ func TestNativeGitRepoPushWritesObjectsAndRefsWithoutBareSync(t *testing.T) { if !isHexHash(strings.TrimSpace(string(refData))) { t.Fatalf("remote ref = %q", string(refData)) } + for _, ref := range []string{"refs/remotes/bucketgit/main", "refs/remotes/origin/main"} { + out, err := runGit(worktree, "rev-parse", "--verify", ref) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != strings.TrimSpace(string(refData)) { + t.Fatalf("%s = %q, want %q", ref, strings.TrimSpace(string(out)), strings.TrimSpace(string(refData))) + } + } stdout.Reset() readRepo := newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: remoteRoot}) @@ -3560,7 +3569,14 @@ func TestNativeGitRepoFetchCopiesObjectsAndRemoteRefs(t *testing.T) { t.Fatal(err) } if !isHexHash(strings.TrimSpace(string(out))) { - t.Fatalf("remote tracking ref = %q", string(out)) + t.Fatalf("bucketgit remote tracking ref = %q", string(out)) + } + out, err = runGit(worktree, "rev-parse", "--verify", "refs/remotes/origin/main") + if err != nil { + t.Fatal(err) + } + if !isHexHash(strings.TrimSpace(string(out))) { + t.Fatalf("origin remote tracking ref = %q", string(out)) } } @@ -3663,6 +3679,16 @@ func TestNativeGitRepoPushDefaultsToCurrentBranch(t *testing.T) { if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "barfoo")); err != nil { t.Fatal(err) } + barfooHash := strings.TrimSpace(string(mustReadFile(t, filepath.Join(remoteRoot, "refs", "heads", "barfoo")))) + for _, ref := range []string{"refs/remotes/bucketgit/barfoo", "refs/remotes/origin/barfoo"} { + out, err := runGit(worktree, "rev-parse", "--verify", ref) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != barfooHash { + t.Fatalf("%s = %q, want %q", ref, strings.TrimSpace(string(out)), barfooHash) + } + } if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "main")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("main ref err = %v", err) } diff --git a/native_git.go b/native_git.go index 23a7dde..c4b08ef 100644 --- a/native_git.go +++ b/native_git.go @@ -358,9 +358,9 @@ func (r *nativeGitRepo) fetchRefsIntoWorktree(ctx context.Context, worktree stri hash := refs[name] switch { case strings.HasPrefix(name, "refs/heads/"): - localRef := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(name, "refs/heads/"))) + localRef := localRemoteTrackingRefPath(gitDir, "bucketgit", name) oldHash := readRefFile(localRef) - if err := writeRefFile(localRef, hash); err != nil { + if err := updateLocalRemoteTrackingRef(gitDir, name, hash); err != nil { return err } short := strings.TrimPrefix(name, "refs/heads/") @@ -698,30 +698,40 @@ func localRemoteTrackingHash(gitDir, ref string) string { if !strings.HasPrefix(ref, "refs/heads/") { return "" } - path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) - data, err := os.ReadFile(path) - if err != nil { - return "" - } - hash := strings.TrimSpace(string(data)) - if !isHexHash(hash) { - return "" + for _, remote := range []string{"bucketgit", "origin"} { + data, err := os.ReadFile(localRemoteTrackingRefPath(gitDir, remote, ref)) + if err != nil { + continue + } + hash := strings.TrimSpace(string(data)) + if isHexHash(hash) { + return hash + } } - return hash + return "" } func updateLocalRemoteTrackingRef(gitDir, ref, hash string) error { if !strings.HasPrefix(ref, "refs/heads/") { return nil } - path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) - if hash == zeroObjectID() { - if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + for _, remote := range []string{"bucketgit", "origin"} { + path := localRemoteTrackingRefPath(gitDir, remote, ref) + if hash == zeroObjectID() { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + continue + } + if err := writeRefFile(path, hash); err != nil { return err } - return nil } - return writeRefFile(path, hash) + return nil +} + +func localRemoteTrackingRefPath(gitDir, remote, ref string) string { + return filepath.Join(gitDir, filepath.FromSlash("refs/remotes/"+remote+"/"+strings.TrimPrefix(ref, "refs/heads/"))) } func (r *nativeGitRepo) putFile(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer) error { diff --git a/web.go b/web.go index 17a1b6e..20837ca 100644 --- a/web.go +++ b/web.go @@ -172,10 +172,13 @@ type brokerIssue struct { Status string `json:"status,omitempty"` Lane string `json:"lane,omitempty"` Assignee string `json:"assignee,omitempty"` + Position float64 `json:"position,omitempty"` + Archived bool `json:"archived,omitempty"` Author string `json:"author,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` Comments []brokerIssueReply `json:"comments,omitempty"` + History []brokerIssueEvent `json:"history,omitempty"` } type brokerIssueReply struct { @@ -184,15 +187,28 @@ type brokerIssueReply struct { At string `json:"at,omitempty"` } +type brokerIssueEvent struct { + User string `json:"user,omitempty"` + Action string `json:"action,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + At string `json:"at,omitempty"` + Ref string `json:"ref,omitempty"` + Position string `json:"position,omitempty"` +} + type brokerIssueRequest struct { - Repo brokerRepo `json:"repo"` - ID int `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` - Lane string `json:"lane,omitempty"` - Assignee string `json:"assignee,omitempty"` - Comment string `json:"comment,omitempty"` + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Lane string `json:"lane,omitempty"` + Assignee string `json:"assignee,omitempty"` + Comment string `json:"comment,omitempty"` + AfterID *int `json:"after_id,omitempty"` + Archived bool `json:"archived,omitempty"` + IncludeArchived bool `json:"include_archived,omitempty"` } type boardRenderContext struct { @@ -947,13 +963,20 @@ func (s *webServer) handleAPIIssues(ctx context.Context, w http.ResponseWriter, } func (s *webServer) listIssues(ctx context.Context) ([]brokerIssue, error) { + return s.listIssuesWithOptions(ctx, brokerIssueRequest{Repo: repoForBroker(s.cfg)}) +} + +func (s *webServer) listIssuesWithOptions(ctx context.Context, req brokerIssueRequest) ([]brokerIssue, error) { if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { return nil, errors.New("broker issues unavailable") } + if strings.TrimSpace(req.Repo.Logical) == "" { + req.Repo = repoForBroker(s.cfg) + } var resp struct { Issues []brokerIssue `json:"issues"` } - if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(s.cfg)}, &resp); err != nil { + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/list", req, &resp); err != nil { return nil, err } return resp.Issues, nil @@ -1832,12 +1855,13 @@ func (s *webServer) handleAPIActionBoard(ctx context.Context, w http.ResponseWri Lane string `json:"lane"` Assignee string `json:"assignee"` Comment string `json:"comment"` + AfterID *int `json:"after_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.renderJSONError(w, http.StatusBadRequest, err) return } - payload := brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: req.ID, Type: "story", Title: strings.TrimSpace(req.Title), Body: strings.TrimSpace(req.Body), Lane: strings.TrimSpace(req.Lane), Assignee: strings.TrimSpace(req.Assignee), Comment: strings.TrimSpace(req.Comment)} + payload := brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: req.ID, Type: "story", Title: strings.TrimSpace(req.Title), Body: strings.TrimSpace(req.Body), Lane: strings.TrimSpace(req.Lane), Assignee: strings.TrimSpace(req.Assignee), Comment: strings.TrimSpace(req.Comment), AfterID: req.AfterID} endpoint := "" switch strings.TrimSpace(req.Action) { case "create": @@ -1854,6 +1878,26 @@ func (s *webServer) handleAPIActionBoard(ctx context.Context, w http.ResponseWri s.renderJSONError(w, http.StatusBadRequest, errors.New("story move requires an id and lane")) return } + case "reorder": + endpoint = "/issues/reorder" + if payload.ID <= 0 || payload.Lane == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("story reorder requires an id and lane")) + return + } + case "edit": + endpoint = "/issues/update" + if payload.ID <= 0 || payload.Body == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("story edit requires an id and story")) + return + } + payload.Title = firstNonEmpty(payload.Title, storySummary(payload.Body)) + case "archive", "unarchive": + endpoint = "/issues/archive" + payload.Archived = strings.TrimSpace(req.Action) == "archive" + if payload.ID <= 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("story archive requires an id")) + return + } case "take": endpoint = "/issues/take" if payload.ID <= 0 { @@ -1878,7 +1922,7 @@ func (s *webServer) handleAPIActionBoard(ctx context.Context, w http.ResponseWri } var resp map[string]any if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, payload, &resp); err != nil { - s.renderJSONError(w, http.StatusBadRequest, err) + s.renderJSONError(w, http.StatusBadRequest, boardUpgradeError(err)) return } s.renderJSON(w, map[string]any{"ok": true}) @@ -2711,7 +2755,8 @@ func (s *webServer) handleIssues(ctx context.Context, w http.ResponseWriter, r * } func (s *webServer) handleBoard(ctx context.Context, w http.ResponseWriter, r *http.Request) { - issues, err := s.listIssues(ctx) + archived := r.URL.Query().Get("view") == "archived" || r.URL.Query().Get("archived") == "1" + issues, err := s.listIssuesWithOptions(ctx, brokerIssueRequest{Repo: repoForBroker(s.cfg), Type: "story", IncludeArchived: archived}) if err != nil { issues = nil } @@ -2720,8 +2765,16 @@ func (s *webServer) handleBoard(ctx context.Context, w http.ResponseWriter, r *h var body strings.Builder body.WriteString(`
`) body.WriteString(s.headerHTML(ref, "board")) - body.WriteString(`
Task board
`) - body.WriteString(boardHTML(issues, boardCtx)) + body.WriteString(`
Task boardActiveArchived
`) + body.WriteString(boardHTML(issues, boardCtx, archived)) if err != nil { body.WriteString(`
` + html.EscapeString(err.Error()) + `
`) } @@ -2825,9 +2878,18 @@ func (s *webServer) handleStory(ctx context.Context, w http.ResponseWriter, r *h body.WriteString(s.headerHTML(ref, "board")) body.WriteString(`
`) body.WriteString(`
` + html.EscapeString(kanbanLaneLabel(lane)) + `

Story ` + html.EscapeString(storyDisplayID(boardCtx.Monogram, story.ID)) + `

`) + body.WriteString(``) body.WriteString(`
` + html.EscapeString(storyText(story)) + `
`) body.WriteString(`
`) body.WriteString(storyAssignmentControlsHTML(story, boardCtx)) + body.WriteString(``) body.WriteString(``) for _, option := range kanbanLanes() { @@ -4498,6 +4634,14 @@ func boardHTML(issues []brokerIssue, ctx boardRenderContext) string { return b.String() } +func editStoryIconHTML() string { + return `` +} + +func archiveStoryIconHTML() string { + return `` +} + func storyAssignmentControlsHTML(story brokerIssue, ctx boardRenderContext) string { assignee := strings.TrimSpace(story.Assignee) if assignee != "" { diff --git a/www/app.css b/www/app.css index b6307a0..68a62f0 100644 --- a/www/app.css +++ b/www/app.css @@ -2608,6 +2608,11 @@ h2 { padding: 14px; overflow-x: auto; } +.archived-stories { + display: grid; + gap: 10px; + padding: 14px; +} .kanban-lane { min-width: 150px; border: 1px solid var(--repo-border); @@ -2788,6 +2793,41 @@ h2 { line-height: 1.5; white-space: pre-wrap; } +.story-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 14px 16px 0; + color: var(--muted); + font-size: 12px; +} +.story-meta span { + padding: 3px 8px; + border: 1px solid var(--repo-border); + border-radius: 999px; + background: var(--surface); +} +.story-history { + display: grid; + gap: 8px; + margin: 14px 16px; + padding: 12px; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); +} +.story-history h2 { + margin: 0; + color: var(--heading); + font-size: 13px; +} +.story-history div { + color: var(--text); + font-size: 13px; +} +.story-history span { + color: var(--muted); +} .story-detail-actions { display: flex; gap: 10px; diff --git a/www/app.js b/www/app.js index bf56114..f8122b1 100644 --- a/www/app.js +++ b/www/app.js @@ -347,8 +347,10 @@ document.addEventListener('drop', function (event) { const id = Number(event.dataTransfer.getData('text/plain') || 0); const fromLane = event.dataTransfer.getData('application/x-bgit-story-lane') || ''; const toLane = lane.getAttribute('data-board-drop-lane') || ''; - if (!id || !toLane || fromLane === toLane) return; - moveBoardStory(id, toLane); + if (!id || !toLane) return; + const afterID = boardDropAfterID(lane, event.clientY, id); + if (fromLane === toLane) reorderBoardStory(id, toLane, afterID); + else moveBoardStory(id, toLane, afterID); }); document.addEventListener('submit', function (event) { @@ -1177,6 +1179,12 @@ async function handleBoardForm(form) { setSyncStatus('Story comment is required.', 'is-stale'); return; } + } else if (action === 'edit') { + payload.body = formValue(form, 'body'); + if (!payload.id || !payload.body) { + setSyncStatus('Story is required.', 'is-stale'); + return; + } } else { payload.body = formValue(form, 'body'); payload.lane = 'backlog'; @@ -1267,6 +1275,10 @@ async function handleBoardAction(button) { const card = button.closest('[data-story-id]'); const id = Number(card?.getAttribute('data-story-id') || 0); if (!id || !action) return; + if (action === 'edit') { + await editBoardStory(card, id); + return; + } try { await postJSON('/api/actions/board', {action, id}); window.location.reload(); @@ -1275,6 +1287,35 @@ async function handleBoardAction(button) { } } +async function editBoardStory(card, id) { + if (!hasCapability('push')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + const current = (card.querySelector('.story-body') || card.querySelector('p'))?.textContent || ''; + const body = await promptModal({ + cardClass: 'story-modal-card', + confirm: 'Save story', + inputLabel: 'Story', + multiline: true, + placeholder: 'As a contributor, I want to describe the work in one clear sentence, so that the team understands the value.', + rows: 6, + value: current, + }); + if (body === false) return; + const story = String(body || '').trim(); + if (!story) { + setSyncStatus('Story is required.', 'is-stale'); + return; + } + try { + await postJSON('/api/actions/board', {action: 'edit', id, body: story}); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } +} + async function createBoardStory() { if (!hasCapability('push')) { setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); @@ -1311,18 +1352,20 @@ async function handleBoardLaneChange(select) { return; } if (!id || !lane) return; - const moved = await moveBoardStory(id, lane); + const moved = await moveBoardStory(id, lane, null); if (moved) hideBoardLaneSelect(select); } -async function moveBoardStory(id, lane) { +async function moveBoardStory(id, lane, afterID) { const card = findBoardStoryCard(id); const targetLane = findBoardLane(lane); if (card && targetLane) { - return await moveBoardStoryOptimistically(card, targetLane, lane); + return await moveBoardStoryOptimistically(card, targetLane, lane, afterID); } try { - await postJSON('/api/actions/board', {action: 'move', id, lane}); + const payload = {action: 'move', id, lane}; + if (Number.isInteger(afterID)) payload.after_id = afterID; + await postJSON('/api/actions/board', payload); window.location.reload(); return true; } catch (err) { @@ -1331,7 +1374,7 @@ async function moveBoardStory(id, lane) { } } -async function moveBoardStoryOptimistically(card, targetLane, lane) { +async function moveBoardStoryOptimistically(card, targetLane, lane, afterID) { const id = Number(card.getAttribute('data-story-id') || 0); const fromLane = card.getAttribute('data-story-lane') || ''; if (!id || !lane || fromLane === lane || card.classList.contains('is-committing')) return true; @@ -1339,12 +1382,14 @@ async function moveBoardStoryOptimistically(card, targetLane, lane) { const originalNextSibling = card.nextElementSibling; const laneSelect = card.querySelector('[data-board-lane]'); - targetLane.appendChild(card); + insertBoardCardAfter(targetLane, card, afterID); card.setAttribute('data-story-lane', lane); if (laneSelect) laneSelect.value = lane; setBoardStoryCommitting(card, true); try { - await postJSON('/api/actions/board', {action: 'move', id, lane}); + const payload = {action: 'move', id, lane}; + if (Number.isInteger(afterID)) payload.after_id = afterID; + await postJSON('/api/actions/board', payload); setBoardStoryCommitting(card, false); return true; } catch (err) { @@ -1359,6 +1404,63 @@ async function moveBoardStoryOptimistically(card, targetLane, lane) { } } +async function reorderBoardStory(id, lane, afterID) { + const card = findBoardStoryCard(id); + const targetLane = findBoardLane(lane); + if (!card || !targetLane || card.classList.contains('is-committing')) return false; + const originalParent = card.parentElement; + const originalNextSibling = card.nextElementSibling; + insertBoardCardAfter(targetLane, card, afterID); + setBoardStoryCommitting(card, true); + try { + await postJSON('/api/actions/board', {action: 'reorder', id, lane, after_id: afterID || 0}); + setBoardStoryCommitting(card, false); + return true; + } catch (err) { + if (originalParent) { + originalParent.insertBefore(card, originalNextSibling && originalNextSibling.parentElement === originalParent ? originalNextSibling : null); + } + setBoardStoryCommitting(card, false); + setSyncStatus(compactError(err), 'is-stale'); + return false; + } +} + +function insertBoardCardAfter(lane, card, afterID) { + if (!Number.isInteger(afterID)) { + lane.appendChild(card); + return; + } + if (!afterID) { + const first = firstBoardStoryCard(lane, card); + lane.insertBefore(card, first); + return; + } + const after = Array.from(lane.querySelectorAll('[data-story-id]')).find((candidate) => + Number(candidate.getAttribute('data-story-id') || 0) === Number(afterID || 0) + ); + lane.insertBefore(card, after ? after.nextElementSibling : null); +} + +function firstBoardStoryCard(lane, except) { + for (const candidate of lane.querySelectorAll('[data-story-id]')) { + if (candidate !== except) return candidate; + } + return null; +} + +function boardDropAfterID(lane, clientY, draggedID) { + let afterID = 0; + for (const card of lane.querySelectorAll('[data-story-id]')) { + const id = Number(card.getAttribute('data-story-id') || 0); + if (!id || id === Number(draggedID || 0)) continue; + const rect = card.getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) return afterID; + afterID = id; + } + return afterID; +} + function hideBoardLaneSelect(select) { const actions = select.closest('.story-actions, .story-detail-actions'); select.hidden = true; @@ -2341,6 +2443,9 @@ function modalDialog(options) { document.body.appendChild(overlay); const input = overlay.querySelector('[data-modal-input]'); const error = overlay.querySelector('[data-modal-error]'); + if (input && Object.prototype.hasOwnProperty.call(options, 'value')) { + input.value = String(options.value || ''); + } const close = function (value) { overlay.remove(); resolve(value); @@ -2453,7 +2558,7 @@ function updatePullRequestUI(prs) { if (tab) tab.hidden = prs.length === 0; const count = document.querySelector('[data-pr-tab-count]'); if (count) { - count.textContent = String(prs.length); + count.textContent = String(openPullRequestCount(prs)); } const list = document.querySelector('[data-pr-list]'); if (list) { @@ -2461,6 +2566,12 @@ function updatePullRequestUI(prs) { } } +function openPullRequestCount(prs) { + return prs.filter(function (pr) { + return String((pr && pr.status) || 'open').toLowerCase() === 'open'; + }).length; +} + function pullRequestListHTML(prs) { if (!prs.length) return '
No pull requests found.
'; return '
    ' + prs.map(function (pr) { From 95b3b09532db31e1915e9afbb20e89be94669e8e Mon Sep 17 00:00:00 2001 From: Dennis Vink Date: Mon, 1 Jun 2026 10:37:00 +0200 Subject: [PATCH 3/3] Update README.md with new board features --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b2c2bd5..42561d4 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,13 @@ bgit pr merge 1 bgit board list bgit board create "As a maintainer, I want clear setup docs so that new users can bootstrap quickly." +bgit board edit BG-1 "As a maintainer, I want clear setup docs so that new users can bootstrap quickly." bgit board take BG-1 +bgit board assign BG-1 ada bgit board move BG-1 doing bgit board comment BG-1 "Opened PR #2." +bgit board archive BG-1 +bgit board list --archived bgit issue create "Bug report" --body "Details" bgit issue list @@ -416,20 +420,27 @@ PR. Broker-backed repositories have a task board immediately; no board creation is required. Stories are stored in repository metadata and move through `backlog`, `ready`, `doing`, `review`, and `done`. Viewers can read the board; -developers and higher can create stories, take or reassign work, move cards, and -comment. +developers and higher can create and edit stories, take or reassign work, move +and reorder cards, archive completed stories, and comment. ```bash bgit board list bgit board create "As a developer, I want CI logs on each run so that failures are easy to diagnose." +bgit board edit BG-1 "As a developer, I want CI logs and status on each run so that failures are easy to diagnose." bgit board take BG-1 +bgit board assign BG-1 ada bgit board move BG-1 review bgit board comment BG-1 "PR #4 is ready for review." +bgit board archive BG-1 +bgit board list --archived ``` Story IDs are prefixed with a repository monogram. The web board supports -drag-and-drop lane moves, assignment controls, comments, optimistic committing -state, and an "Only me" filter for assigned work. +drag-and-drop lane moves and same-lane reordering, assignment controls, +comments, archived-story browsing, story activity history, optimistic committing +state, and an "Only me" filter for assigned work. Older brokers can still list +and move existing stories; edit, archive, and ordering operations require a +broker upgrade. ## CI/CD @@ -508,7 +519,9 @@ git push Native Git transport is authorized through the broker. Ref updates use compare-and-swap checks so stale writers are rejected instead of silently -overwriting refs. +overwriting refs. `bgit fetch` and `bgit push` keep both `bucketgit/*` and +matching `origin/*` remote-tracking refs current for branch refs, so native +`git status` reports the same ahead/behind state after bgit operations. ## Direct Bucket Mode