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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
101 changes: 73 additions & 28 deletions broker/aws/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand All @@ -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) {
Expand Down
105 changes: 77 additions & 28 deletions broker/gcp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
49 changes: 48 additions & 1 deletion broker_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading