Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -579,7 +592,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

Expand Down
114 changes: 111 additions & 3 deletions broker/aws/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down Expand Up @@ -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") {
Expand All @@ -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});
Expand All @@ -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});
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Loading