///| Issue operations
///|
/// Create a new Issue
pub fn Hub::create_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
title : String,
body : String,
author : String,
labels? : Array[String] = [],
assignees? : Array[String] = [],
parent_id? : String? = None,
) -> Issue raise @bit.GitError {
let timestamp = clock.now()
let issue_id = generate_entity_id(
"issue",
author,
timestamp,
title + "\n" + body,
)
let issue = Issue::new(
issue_id,
title,
body,
author,
timestamp,
timestamp,
IssueState::Open,
labels~,
assignees~,
parent_id~,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
issue.to_work_item().serialize(),
author,
),
)
issue
}
///|
/// Get an Issue by ID
pub fn Hub::get_issue(
self : Hub,
objects : &@lib.ObjectStore,
issue_id : String,
) -> Issue? {
let work_item = self.get_work_item(objects, issue_id)
match work_item {
None => None
Some(item) => item.to_issue()
}
}
///|
/// List Issues, optionally filtered by state
pub fn Hub::list_issues(
self : Hub,
objects : &@lib.ObjectStore,
state? : IssueState? = None,
) -> Array[Issue] {
let item_state = match state {
None => None
Some(s) => Some(s.to_work_item_state())
}
let items = self.list_work_items(
objects,
state=item_state,
kind=Some(WorkItemKind::Issue),
)
let result : Array[Issue] = []
for item in items {
match item.to_issue() {
Some(issue) => result.push(issue)
None => ()
}
}
result
}
///|
/// Update an Issue (title, body, labels, assignees)
pub fn Hub::update_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
title? : String? = None,
body? : String? = None,
labels? : Array[String]? = None,
assignees? : Array[String]? = None,
) -> Issue raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(existing) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
let new_title = match title {
Some(t) => t
None => existing.title
}
let new_body = match body {
Some(b) => b
None => existing.body
}
let new_labels = match labels {
Some(l) => l
None => existing.labels
}
let new_assignees = match assignees {
Some(a) => a
None => existing.assignees
}
let updated = Issue::new(
existing.id,
new_title,
new_body,
existing.author,
existing.created_at,
clock.now(),
existing.state,
labels=new_labels,
assignees=new_assignees,
linked_prs=existing.linked_prs,
linked_issues=existing.linked_issues,
parent_id=existing.parent_id,
blocked_by=existing.blocked_by,
blocking=existing.blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
updated
}
///|
/// Close an Issue
pub fn Hub::close_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
) -> Unit raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(existing) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
if existing.state != IssueState::Open {
raise @bit.GitError::InvalidObject("Issue is not open: \{issue_id}")
}
let updated = Issue::new(
existing.id,
existing.title,
existing.body,
existing.author,
existing.created_at,
clock.now(),
IssueState::Closed,
labels=existing.labels,
assignees=existing.assignees,
linked_prs=existing.linked_prs,
parent_id=existing.parent_id,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
}
///|
/// Reopen a closed Issue
pub fn Hub::reopen_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
) -> Unit raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(existing) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
if existing.state != IssueState::Closed {
raise @bit.GitError::InvalidObject("Issue is not closed: \{issue_id}")
}
let updated = Issue::new(
existing.id,
existing.title,
existing.body,
existing.author,
existing.created_at,
clock.now(),
IssueState::Open,
labels=existing.labels,
assignees=existing.assignees,
linked_prs=existing.linked_prs,
parent_id=existing.parent_id,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
}
///|
/// List sub-issues of a parent issue
pub fn Hub::list_sub_issues(
self : Hub,
objects : &@lib.ObjectStore,
parent_id : String,
state? : IssueState? = None,
) -> Array[Issue] {
let all_issues = self.list_issues(objects, state~)
let result : Array[Issue] = []
for issue in all_issues {
if issue.parent_id == Some(parent_id) {
result.push(issue)
}
}
result
}
///|
/// Add a comment to an Issue
pub fn Hub::add_issue_comment(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
author : String,
body : String,
reply_to? : String? = None,
) -> IssueComment raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(_) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
let timestamp = clock.now()
let comment = IssueComment::new(
"",
issue_id,
author,
body,
timestamp,
reply_to~,
)
let comment_data = comment.serialize()
let (blob_id, _compressed) = @bit.create_blob_string(comment_data)
let comment_id = short_hex(blob_id.to_hex(), 8)
let final_comment = IssueComment::new(
comment_id,
issue_id,
author,
body,
timestamp,
reply_to~,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
issue_comment_key(issue_id, comment_id),
"issue.comment",
final_comment.serialize(),
author,
),
)
final_comment
}
///|
/// Update an existing comment on an Issue (replace body, keep id/timestamps)
pub fn Hub::update_issue_comment(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
comment_id : String,
body : String,
author : String,
) -> Unit raise @bit.GitError {
let updated_comment = IssueComment::new(
comment_id,
issue_id,
author,
body,
clock.now(),
)
ignore(
self.store.put_record(
objects,
refs,
clock,
issue_comment_key(issue_id, comment_id),
"issue.comment",
updated_comment.serialize(),
author,
),
)
}
///|
/// Delete a comment from an Issue
pub fn Hub::delete_issue_comment(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
comment_id : String,
) -> Unit raise @bit.GitError {
ignore(
self.store.delete_record(
objects,
refs,
clock,
issue_comment_key(issue_id, comment_id),
"issue.comment",
"github-sync",
),
)
}
///|
/// List comments for an Issue
pub fn Hub::list_issue_comments(
self : Hub,
objects : &@lib.ObjectStore,
issue_id : String,
) -> Array[IssueComment] {
let result : Array[IssueComment] = []
let records = self.store.list_records(objects, issue_comment_prefix(issue_id))
for record in records {
if record.kind != "issue.comment" {
continue
}
let comment = parse_issue_comment(record.payload) catch { _ => continue }
result.push(comment)
}
result.sort_by(fn(a, b) {
if a.created_at < b.created_at {
-1
} else if a.created_at > b.created_at {
1
} else {
0
}
})
result
}
///|
/// Link a cross-repo issue reference to an Issue
pub fn Hub::link_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
target_ref : String,
) -> Unit raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(existing) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
let linked = existing.linked_issues
if !linked.contains(target_ref) {
linked.push(target_ref)
}
let updated = Issue::new(
existing.id,
existing.title,
existing.body,
existing.author,
existing.created_at,
clock.now(),
existing.state,
labels=existing.labels,
assignees=existing.assignees,
linked_prs=existing.linked_prs,
linked_issues=linked,
parent_id=existing.parent_id,
blocked_by=existing.blocked_by,
blocking=existing.blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
}
///|
/// Link a PR to an Issue
pub fn Hub::link_pr_to_issue(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
issue_id : String,
pr_id : String,
) -> Unit raise @bit.GitError {
let issue = self.get_issue(objects, issue_id)
guard issue is Some(existing) else {
raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
}
let linked = existing.linked_prs
if !linked.contains(pr_id) {
linked.push(pr_id)
}
let updated = Issue::new(
existing.id,
existing.title,
existing.body,
existing.author,
existing.created_at,
clock.now(),
existing.state,
labels=existing.labels,
assignees=existing.assignees,
linked_prs=linked,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(issue_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
}
///|
/// Returns true if `to_id` is reachable from `from_id` by following
/// `blocked_by` edges, i.e. `from_id` already (transitively) depends on
/// `to_id`. Used to reject dependency edges that would close a cycle.
fn Hub::dependency_path_exists(
self : Hub,
objects : &@lib.ObjectStore,
from_id : String,
to_id : String,
) -> Bool raise @bit.GitError {
if from_id == to_id {
return true
}
let visited : Array[String] = []
let queue : Array[String] = [from_id]
let mut head = 0
while head < queue.length() {
let current = queue[head]
head += 1
if visited.contains(current) {
continue
}
visited.push(current)
match self.get_issue(objects, current) {
Some(issue) =>
for dep in issue.blocked_by() {
if dep == to_id {
return true
}
if !visited.contains(dep) {
queue.push(dep)
}
}
None => ()
}
}
false
}
///|
/// Add a dependency relationship between two issues.
/// `source_id` is blocked by `target_id`.
/// Both issues are updated in separate put_record calls.
pub fn Hub::add_dep(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
source_id : String,
target_id : String,
) -> Unit raise @bit.GitError {
if source_id == target_id {
raise @bit.GitError::InvalidObject(
"Issue cannot depend on itself: \{source_id}",
)
}
let source = self.get_issue(objects, source_id)
guard source is Some(src) else {
raise @bit.GitError::InvalidObject("Issue not found: \{source_id}")
}
let target = self.get_issue(objects, target_id)
guard target is Some(tgt) else {
raise @bit.GitError::InvalidObject("Issue not found: \{target_id}")
}
if
!src.blocked_by.contains(target_id) &&
self.dependency_path_exists(objects, target_id, source_id) {
raise @bit.GitError::InvalidObject(
"Adding this dependency would create a cycle: #\{target_id} already depends on #\{source_id}",
)
}
if !src.blocked_by.contains(target_id) {
let new_blocked_by = src.blocked_by.copy()
new_blocked_by.push(target_id)
let updated_src = Issue::new(
src.id,
src.title,
src.body,
src.author,
src.created_at,
clock.now(),
src.state,
labels=src.labels,
assignees=src.assignees,
linked_prs=src.linked_prs,
linked_issues=src.linked_issues,
parent_id=src.parent_id,
blocked_by=new_blocked_by,
blocking=src.blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(source_id),
canonical_work_item_record_kind(),
updated_src.to_work_item().serialize(),
src.author,
),
)
}
if !tgt.blocking.contains(source_id) {
let new_blocking = tgt.blocking.copy()
new_blocking.push(source_id)
let updated_tgt = Issue::new(
tgt.id,
tgt.title,
tgt.body,
tgt.author,
tgt.created_at,
clock.now(),
tgt.state,
labels=tgt.labels,
assignees=tgt.assignees,
linked_prs=tgt.linked_prs,
linked_issues=tgt.linked_issues,
parent_id=tgt.parent_id,
blocked_by=tgt.blocked_by,
blocking=new_blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(target_id),
canonical_work_item_record_kind(),
updated_tgt.to_work_item().serialize(),
tgt.author,
),
)
}
}
///|
/// Remove a dependency relationship between two issues.
/// `source_id` is no longer blocked by `target_id`.
pub fn Hub::remove_dep(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
source_id : String,
target_id : String,
) -> Unit raise @bit.GitError {
let source = self.get_issue(objects, source_id)
guard source is Some(src) else {
raise @bit.GitError::InvalidObject("Issue not found: \{source_id}")
}
let target = self.get_issue(objects, target_id)
guard target is Some(tgt) else {
raise @bit.GitError::InvalidObject("Issue not found: \{target_id}")
}
let new_blocked_by = src.blocked_by.filter(fn(id) { id != target_id })
let updated_src = Issue::new(
src.id,
src.title,
src.body,
src.author,
src.created_at,
clock.now(),
src.state,
labels=src.labels,
assignees=src.assignees,
linked_prs=src.linked_prs,
linked_issues=src.linked_issues,
parent_id=src.parent_id,
blocked_by=new_blocked_by,
blocking=src.blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(source_id),
canonical_work_item_record_kind(),
updated_src.to_work_item().serialize(),
src.author,
),
)
let new_blocking = tgt.blocking.filter(fn(id) { id != source_id })
let updated_tgt = Issue::new(
tgt.id,
tgt.title,
tgt.body,
tgt.author,
tgt.created_at,
clock.now(),
tgt.state,
labels=tgt.labels,
assignees=tgt.assignees,
linked_prs=tgt.linked_prs,
linked_issues=tgt.linked_issues,
parent_id=tgt.parent_id,
blocked_by=tgt.blocked_by,
blocking=new_blocking,
)
ignore(
self.store.put_record(
objects,
refs,
clock,
work_item_meta_key(target_id),
canonical_work_item_record_kind(),
updated_tgt.to_work_item().serialize(),
tgt.author,
),
)
}