Skip to content

Commit 25a6c2c

Browse files
thrashr888claude
andcommitted
feat(integrations): add milestone/version sync support
Add CRUD methods for milestones in GitHub adapter and versions in JIRA adapter: - GitHubAdapter: list, get, create, update, delete milestones; assign/unassign issues - JiraAdapter: list, get, create, update, delete versions; assign/remove issues from versions This enables bi-directional sync between AllBeads milestones and external project management tools. Closes: ab-ozam, ab-90ke Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0606710 commit 25a6c2c

2 files changed

Lines changed: 565 additions & 0 deletions

File tree

src/integrations/github.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ pub struct GitHubComment {
7474
pub updated_at: String,
7575
}
7676

77+
/// GitHub milestone (REST API format)
78+
#[derive(Debug, Clone, Serialize, Deserialize)]
79+
pub struct GitHubMilestone {
80+
pub number: u64,
81+
pub id: u64,
82+
pub title: String,
83+
#[serde(default)]
84+
pub description: Option<String>,
85+
pub state: String, // "open" or "closed"
86+
#[serde(default)]
87+
pub due_on: Option<String>,
88+
pub html_url: String,
89+
pub created_at: String,
90+
pub updated_at: String,
91+
#[serde(default)]
92+
pub closed_at: Option<String>,
93+
pub open_issues: u32,
94+
pub closed_issues: u32,
95+
}
96+
97+
/// Request to create/update a milestone
98+
#[derive(Debug, Clone, Serialize)]
99+
pub struct MilestoneRequest {
100+
pub title: String,
101+
#[serde(skip_serializing_if = "Option::is_none")]
102+
pub description: Option<String>,
103+
#[serde(skip_serializing_if = "Option::is_none")]
104+
pub due_on: Option<String>,
105+
#[serde(skip_serializing_if = "Option::is_none")]
106+
pub state: Option<String>,
107+
}
108+
77109
/// GraphQL response wrapper
78110
#[derive(Debug, Clone, Deserialize)]
79111
struct GraphQLResponse<T> {
@@ -771,6 +803,243 @@ impl GitHubAdapter {
771803
}),
772804
}
773805
}
806+
807+
// ========================================================================
808+
// Milestone Operations
809+
// ========================================================================
810+
811+
/// List all milestones for a repository
812+
pub async fn list_milestones(
813+
&self,
814+
repo: &str,
815+
state: Option<&str>,
816+
) -> Result<Vec<GitHubMilestone>> {
817+
let url = format!(
818+
"{}/repos/{}/{}/milestones",
819+
self.rest_base_url, self.config.owner, repo
820+
);
821+
822+
debug!(repo = %repo, state = ?state, "Listing GitHub milestones");
823+
824+
let mut request = self.client.get(&url);
825+
if let Some(state) = state {
826+
request = request.query(&[("state", state)]);
827+
}
828+
request = request.query(&[("sort", "due_on"), ("direction", "asc")]);
829+
830+
if let Some(ref token) = self.auth_token {
831+
request = request.bearer_auth(token);
832+
}
833+
834+
let response = request.timeout(GET_TIMEOUT).send().await?;
835+
836+
match response.status() {
837+
StatusCode::OK => {
838+
let milestones: Vec<GitHubMilestone> = response.json().await?;
839+
info!(count = milestones.len(), "Fetched GitHub milestones");
840+
Ok(milestones)
841+
}
842+
StatusCode::NOT_FOUND => Err(crate::AllBeadsError::Integration(format!(
843+
"Repository not found: {}/{}",
844+
self.config.owner, repo
845+
))),
846+
status => {
847+
let error_body = response.text().await.unwrap_or_default();
848+
Err(crate::AllBeadsError::Integration(format!(
849+
"GitHub milestones failed: HTTP {}: {}",
850+
status, error_body
851+
)))
852+
}
853+
}
854+
}
855+
856+
/// Get a single milestone by number
857+
pub async fn get_milestone(&self, repo: &str, number: u64) -> Result<GitHubMilestone> {
858+
let url = format!(
859+
"{}/repos/{}/{}/milestones/{}",
860+
self.rest_base_url, self.config.owner, repo, number
861+
);
862+
863+
debug!(repo = %repo, number = %number, "Fetching GitHub milestone");
864+
865+
let mut request = self.client.get(&url);
866+
if let Some(ref token) = self.auth_token {
867+
request = request.bearer_auth(token);
868+
}
869+
870+
let response = request.timeout(GET_TIMEOUT).send().await?;
871+
872+
match response.status() {
873+
StatusCode::OK => Ok(response.json().await?),
874+
StatusCode::NOT_FOUND => Err(crate::AllBeadsError::Integration(format!(
875+
"Milestone not found: {}/{}#{}",
876+
self.config.owner, repo, number
877+
))),
878+
status => {
879+
let error_body = response.text().await.unwrap_or_default();
880+
Err(crate::AllBeadsError::Integration(format!(
881+
"GitHub milestone fetch failed: HTTP {}: {}",
882+
status, error_body
883+
)))
884+
}
885+
}
886+
}
887+
888+
/// Create a new milestone
889+
pub async fn create_milestone(
890+
&self,
891+
repo: &str,
892+
request: MilestoneRequest,
893+
) -> Result<GitHubMilestone> {
894+
let url = format!(
895+
"{}/repos/{}/{}/milestones",
896+
self.rest_base_url, self.config.owner, repo
897+
);
898+
899+
info!(repo = %repo, title = %request.title, "Creating GitHub milestone");
900+
901+
let mut http_request = self.client.post(&url).json(&request);
902+
if let Some(ref token) = self.auth_token {
903+
http_request = http_request.bearer_auth(token);
904+
}
905+
906+
let response = http_request.timeout(WRITE_TIMEOUT).send().await?;
907+
908+
match response.status() {
909+
StatusCode::CREATED => {
910+
let milestone: GitHubMilestone = response.json().await?;
911+
info!(number = milestone.number, "GitHub milestone created");
912+
Ok(milestone)
913+
}
914+
StatusCode::UNPROCESSABLE_ENTITY => {
915+
let error_body = response.text().await.unwrap_or_default();
916+
Err(crate::AllBeadsError::Integration(format!(
917+
"Invalid milestone data: {}",
918+
error_body
919+
)))
920+
}
921+
status => {
922+
let error_body = response.text().await.unwrap_or_default();
923+
Err(crate::AllBeadsError::Integration(format!(
924+
"GitHub milestone creation failed: HTTP {}: {}",
925+
status, error_body
926+
)))
927+
}
928+
}
929+
}
930+
931+
/// Update an existing milestone
932+
pub async fn update_milestone(
933+
&self,
934+
repo: &str,
935+
number: u64,
936+
request: MilestoneRequest,
937+
) -> Result<GitHubMilestone> {
938+
let url = format!(
939+
"{}/repos/{}/{}/milestones/{}",
940+
self.rest_base_url, self.config.owner, repo, number
941+
);
942+
943+
info!(repo = %repo, number = %number, "Updating GitHub milestone");
944+
945+
let mut http_request = self.client.patch(&url).json(&request);
946+
if let Some(ref token) = self.auth_token {
947+
http_request = http_request.bearer_auth(token);
948+
}
949+
950+
let response = http_request.timeout(WRITE_TIMEOUT).send().await?;
951+
952+
match response.status() {
953+
StatusCode::OK => Ok(response.json().await?),
954+
StatusCode::NOT_FOUND => Err(crate::AllBeadsError::Integration(format!(
955+
"Milestone not found: {}/{}#{}",
956+
self.config.owner, repo, number
957+
))),
958+
status => {
959+
let error_body = response.text().await.unwrap_or_default();
960+
Err(crate::AllBeadsError::Integration(format!(
961+
"GitHub milestone update failed: HTTP {}: {}",
962+
status, error_body
963+
)))
964+
}
965+
}
966+
}
967+
968+
/// Delete a milestone
969+
pub async fn delete_milestone(&self, repo: &str, number: u64) -> Result<()> {
970+
let url = format!(
971+
"{}/repos/{}/{}/milestones/{}",
972+
self.rest_base_url, self.config.owner, repo, number
973+
);
974+
975+
info!(repo = %repo, number = %number, "Deleting GitHub milestone");
976+
977+
let mut request = self.client.delete(&url);
978+
if let Some(ref token) = self.auth_token {
979+
request = request.bearer_auth(token);
980+
}
981+
982+
let response = request.timeout(WRITE_TIMEOUT).send().await?;
983+
984+
match response.status() {
985+
StatusCode::NO_CONTENT => Ok(()),
986+
StatusCode::NOT_FOUND => Err(crate::AllBeadsError::Integration(format!(
987+
"Milestone not found: {}/{}#{}",
988+
self.config.owner, repo, number
989+
))),
990+
status => {
991+
let error_body = response.text().await.unwrap_or_default();
992+
Err(crate::AllBeadsError::Integration(format!(
993+
"GitHub milestone deletion failed: HTTP {}: {}",
994+
status, error_body
995+
)))
996+
}
997+
}
998+
}
999+
1000+
/// Assign an issue to a milestone
1001+
pub async fn assign_issue_to_milestone(
1002+
&self,
1003+
repo: &str,
1004+
issue_number: u64,
1005+
milestone_number: u64,
1006+
) -> Result<GitHubIssue> {
1007+
let url = format!(
1008+
"{}/repos/{}/{}/issues/{}",
1009+
self.rest_base_url, self.config.owner, repo, issue_number
1010+
);
1011+
1012+
info!(
1013+
repo = %repo,
1014+
issue = %issue_number,
1015+
milestone = %milestone_number,
1016+
"Assigning issue to milestone"
1017+
);
1018+
1019+
let body = serde_json::json!({ "milestone": milestone_number });
1020+
1021+
let mut request = self.client.patch(&url).json(&body);
1022+
if let Some(ref token) = self.auth_token {
1023+
request = request.bearer_auth(token);
1024+
}
1025+
1026+
let response = request.timeout(WRITE_TIMEOUT).send().await?;
1027+
1028+
match response.status() {
1029+
StatusCode::OK => Ok(response.json().await?),
1030+
StatusCode::NOT_FOUND => Err(crate::AllBeadsError::Integration(format!(
1031+
"Issue or milestone not found: {}/{}#{}",
1032+
self.config.owner, repo, issue_number
1033+
))),
1034+
status => {
1035+
let error_body = response.text().await.unwrap_or_default();
1036+
Err(crate::AllBeadsError::Integration(format!(
1037+
"GitHub issue update failed: HTTP {}: {}",
1038+
status, error_body
1039+
)))
1040+
}
1041+
}
1042+
}
7741043
}
7751044

7761045
#[cfg(test)]

0 commit comments

Comments
 (0)