Support multiple merge requests per ticket
This commit is contained in:
+377
-46
@@ -97,6 +97,8 @@ pub struct ReviewRequestedEvent {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub sequence: u64,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub requested_by: WorkerIdentity,
|
||||
pub reviewer: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -108,6 +110,8 @@ pub struct ReviewEvent {
|
||||
pub sequence: u64,
|
||||
pub request_event_id: String,
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
pub decision: ReviewDecision,
|
||||
pub body: String,
|
||||
pub findings: Vec<ReviewFinding>,
|
||||
@@ -259,7 +263,9 @@ pub struct OpenMergeRequest {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub ticket_item_revision: String,
|
||||
pub subject_ref: String,
|
||||
pub child_session_id: String,
|
||||
pub capability_token: String,
|
||||
@@ -282,11 +288,14 @@ pub struct RegisterReviewerChildSession {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewSubmissionAuthorization {
|
||||
pub workspace_id: String,
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub subject_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubmitMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub current_subject_ref: String,
|
||||
pub capability_token: String,
|
||||
@@ -297,6 +306,7 @@ pub struct SubmitMergeRequestReview {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RevokeMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub review_event_id: String,
|
||||
pub reason: String,
|
||||
@@ -306,6 +316,7 @@ pub struct RevokeMergeRequestReview {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepairSelectorFrom {
|
||||
pub workspace_id: String,
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub selector_from: String,
|
||||
pub resolved_subject_ref: String,
|
||||
@@ -315,6 +326,7 @@ pub struct RepairSelectorFrom {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadinessCheck {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub current_subject_ref: Option<String>,
|
||||
pub auth: MergeRequestAuth,
|
||||
@@ -330,6 +342,7 @@ pub struct ReadinessReport {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompleteMergeRequest {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub operation_id: String,
|
||||
pub approval_event_id: String,
|
||||
@@ -341,6 +354,24 @@ pub struct CompleteMergeRequest {
|
||||
pub auth: MergeRequestAuth,
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompleteTicket {
|
||||
pub ticket_id: String,
|
||||
pub operation_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub auth: MergeRequestAuth,
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
pub ticket_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub completed_by: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CurrentAssignment {
|
||||
pub assignment_id: String,
|
||||
@@ -421,13 +452,14 @@ impl MergeRequestStore {
|
||||
"selectors must differ".into(),
|
||||
));
|
||||
}
|
||||
let merge_request_id = i.merge_request_id.clone();
|
||||
self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?;
|
||||
let mut c = self.lock()?;
|
||||
let t = c.transaction()?;
|
||||
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
|
||||
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.repository_id=?3 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id,i.repository_id],|r|r.get(0))?;
|
||||
if conflict {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
|
||||
"Ticket already has an open Merge Request for this repository; advance that Merge Request's existing selector_from with a normal non-force push instead of opening a replacement".into(),
|
||||
));
|
||||
}
|
||||
let now = i.now.to_rfc3339();
|
||||
@@ -469,7 +501,7 @@ impl MergeRequestStore {
|
||||
}
|
||||
t.commit()?;
|
||||
drop(c);
|
||||
self.get(&i.auth.workspace_id, &i.ticket_id)
|
||||
self.get_by_id(&i.auth.workspace_id, &merge_request_id)
|
||||
}
|
||||
pub fn register_reviewer_child_session(
|
||||
&self,
|
||||
@@ -498,7 +530,12 @@ impl MergeRequestStore {
|
||||
i: RequestMergeRequestReview,
|
||||
) -> Result<RequestedMergeRequestReview, MergeRequestError> {
|
||||
nonempty("subject_ref", &i.subject_ref)?;
|
||||
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.require_current_ticket_revision(
|
||||
&i.auth.workspace_id,
|
||||
&i.ticket_id,
|
||||
&i.ticket_item_revision,
|
||||
)?;
|
||||
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
if mr.state != MergeRequestState::Open || mr.selector_from.is_none() {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
@@ -522,6 +559,7 @@ impl MergeRequestStore {
|
||||
event_id: Uuid::now_v7().to_string(),
|
||||
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
||||
subject_ref: i.subject_ref,
|
||||
ticket_item_revision: i.ticket_item_revision,
|
||||
requested_by: WorkerIdentity {
|
||||
runtime_id: i.auth.runtime_id,
|
||||
worker_id: i.auth.worker_id,
|
||||
@@ -548,25 +586,27 @@ impl MergeRequestStore {
|
||||
}
|
||||
pub fn authorize_review_submission(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
merge_request_id: &str,
|
||||
capability_token: &str,
|
||||
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
||||
let connection = self.lock()?;
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT g.workspace_id,g.subject_ref
|
||||
"SELECT g.workspace_id,g.merge_request_id,rel.ticket_id,g.subject_ref
|
||||
FROM merge_request_review_grants g
|
||||
JOIN merge_request_ticket_relations rel
|
||||
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
||||
JOIN merge_requests mr
|
||||
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
||||
WHERE g.capability_token=?1 AND rel.ticket_id=?2
|
||||
WHERE g.capability_token=?1 AND g.merge_request_id=?2
|
||||
AND g.status='issued' AND mr.state='open'",
|
||||
params![capability_token, ticket_id],
|
||||
params![capability_token, merge_request_id],
|
||||
|row| {
|
||||
Ok(ReviewSubmissionAuthorization {
|
||||
workspace_id: row.get(0)?,
|
||||
subject_ref: row.get(1)?,
|
||||
merge_request_id: row.get(1)?,
|
||||
ticket_id: row.get(2)?,
|
||||
subject_ref: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -594,8 +634,9 @@ impl MergeRequestStore {
|
||||
JOIN merge_requests mr
|
||||
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
||||
WHERE g.capability_token=?1 AND rel.ticket_id=?2
|
||||
AND g.merge_request_id=?3
|
||||
AND g.status='issued' AND mr.state='open'",
|
||||
params![i.capability_token, i.ticket_id],
|
||||
params![i.capability_token, i.ticket_id, i.merge_request_id],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
@@ -613,6 +654,16 @@ impl MergeRequestStore {
|
||||
"review grant invalid".into(),
|
||||
));
|
||||
};
|
||||
let ticket_item_revision = load_mr(&t, &ws, &mr)?
|
||||
.and_then(|request| {
|
||||
request.thread.into_iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
|
||||
Some(event.ticket_item_revision)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| MergeRequestError::Corrupt("review request event missing".into()))?;
|
||||
if subject != i.current_subject_ref {
|
||||
let reason = format!(
|
||||
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
|
||||
@@ -638,6 +689,7 @@ impl MergeRequestStore {
|
||||
sequence: next_seq(&t, &ws, &mr)?,
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
ticket_item_revision,
|
||||
decision: i.decision,
|
||||
body: i.body,
|
||||
findings: i.findings,
|
||||
@@ -657,7 +709,7 @@ impl MergeRequestStore {
|
||||
i: RevokeMergeRequestReview,
|
||||
) -> Result<ReviewRevokedEvent, MergeRequestError> {
|
||||
bounded_body("revocation reason", &i.reason)?;
|
||||
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
let r = mr
|
||||
.thread
|
||||
@@ -695,7 +747,7 @@ impl MergeRequestStore {
|
||||
Ok(e)
|
||||
}
|
||||
pub fn readiness(&self, i: ReadinessCheck) -> Result<ReadinessReport, MergeRequestError> {
|
||||
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
let review = i
|
||||
.current_subject_ref
|
||||
.as_deref()
|
||||
@@ -742,7 +794,7 @@ impl MergeRequestStore {
|
||||
})
|
||||
}
|
||||
pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> {
|
||||
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.repo(&i.auth, &mr.repository_id)?;
|
||||
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
|
||||
@@ -808,7 +860,8 @@ impl MergeRequestStore {
|
||||
self.validate_completion(&i)?;
|
||||
let mut connection = self.lock()?;
|
||||
let transaction = connection.transaction()?;
|
||||
let mr = load_mr_for_ticket(&transaction, &i.auth.workspace_id, &i.ticket_id)?
|
||||
let mr = load_mr(&transaction, &i.auth.workspace_id, &i.merge_request_id)?
|
||||
.filter(|mr| mr.ticket_ids.iter().any(|id| id == &i.ticket_id))
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
|
||||
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
||||
@@ -872,22 +925,6 @@ impl MergeRequestStore {
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
transaction.execute(
|
||||
"UPDATE typed_tickets
|
||||
SET workflow_state='done',workflow_state_explicit=1,updated_at=?3
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
|
||||
params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()],
|
||||
)?;
|
||||
let released_assignment = transaction.execute(
|
||||
"DELETE FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
|
||||
params![mr.workspace_id, i.ticket_id, i.auth.assignment_id],
|
||||
)?;
|
||||
if released_assignment != 1 {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
"completion assignment changed while closing Ticket".into(),
|
||||
));
|
||||
}
|
||||
let issued_grants = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT request_event_id,subject_ref,capability_token
|
||||
@@ -965,13 +1002,195 @@ impl MergeRequestStore {
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub fn complete_ticket(
|
||||
&self,
|
||||
mut input: CompleteTicket,
|
||||
) -> Result<TicketCompletionEvent, MergeRequestError> {
|
||||
nonempty("operation_id", &input.operation_id)?;
|
||||
nonempty("item_revision", &input.item_revision)?;
|
||||
if input.merge_request_ids.is_empty() {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"Ticket completion requires at least one linked Merge Request result".into(),
|
||||
));
|
||||
}
|
||||
input.merge_request_ids.sort();
|
||||
if input
|
||||
.merge_request_ids
|
||||
.windows(2)
|
||||
.any(|ids| ids[0] == ids[1])
|
||||
{
|
||||
return Err(MergeRequestError::Validation(
|
||||
"merge_request_ids must not contain duplicates".into(),
|
||||
));
|
||||
}
|
||||
let mut connection = self.lock()?;
|
||||
let transaction = connection.transaction()?;
|
||||
let existing: Option<String> = transaction
|
||||
.query_row(
|
||||
"SELECT payload.value
|
||||
FROM typed_ticket_event_attributes operation
|
||||
JOIN typed_ticket_event_attributes payload
|
||||
ON payload.workspace_id=operation.workspace_id
|
||||
AND payload.ticket_id=operation.ticket_id
|
||||
AND payload.event_index=operation.event_index
|
||||
AND payload.key='ticket_completion'
|
||||
WHERE operation.workspace_id=?1 AND operation.ticket_id=?2
|
||||
AND operation.key='operation_id' AND operation.value=?3",
|
||||
params![input.auth.workspace_id, input.ticket_id, input.operation_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(payload) = existing {
|
||||
let event: TicketCompletionEvent = json(&payload)?;
|
||||
if event.item_revision == input.item_revision
|
||||
&& event.merge_request_ids == input.merge_request_ids
|
||||
&& event.completed_by == input.auth.actor()
|
||||
{
|
||||
return Ok(event);
|
||||
}
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket completion operation fingerprint mismatch".into(),
|
||||
));
|
||||
}
|
||||
let current_revision =
|
||||
current_ticket_revision(&transaction, &input.auth.workspace_id, &input.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current_revision != input.item_revision {
|
||||
return Err(MergeRequestError::Conflict(format!(
|
||||
"Ticket item revision changed from `{}` to `{current_revision}`; repeat requirement review before completion",
|
||||
input.item_revision
|
||||
)));
|
||||
}
|
||||
let state: String = transaction.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![input.auth.workspace_id, input.ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if state != "inprogress" {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
let assignment_is_current: bool = transaction.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
|
||||
)",
|
||||
params![
|
||||
input.auth.workspace_id,
|
||||
input.ticket_id,
|
||||
input.auth.assignment_id
|
||||
],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !assignment_is_current {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
"Ticket completion assignment changed before commit".into(),
|
||||
));
|
||||
}
|
||||
let actual_ids = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT merge_request_id FROM merge_request_ticket_relations
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND relation_kind='implements'
|
||||
ORDER BY merge_request_id",
|
||||
)?;
|
||||
statement
|
||||
.query_map(params![input.auth.workspace_id, input.ticket_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
if actual_ids != input.merge_request_ids {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"linked Merge Request set changed; refresh Ticket completion evidence".into(),
|
||||
));
|
||||
}
|
||||
for merge_request_id in &actual_ids {
|
||||
let request = load_mr(&transaction, &input.auth.workspace_id, merge_request_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if request.state != MergeRequestState::Merged {
|
||||
return Err(MergeRequestError::NotReady(format!(
|
||||
"Merge Request `{merge_request_id}` has no merged result"
|
||||
)));
|
||||
}
|
||||
let merge = request
|
||||
.thread
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
MergeRequestThreadEvent::Merge(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
MergeRequestError::Corrupt(format!(
|
||||
"merged Merge Request `{merge_request_id}` has no MergeResult"
|
||||
))
|
||||
})?;
|
||||
let review = request
|
||||
.effective_review(&merge.approved_source_ref)
|
||||
.filter(|review| review.event_id == merge.approval_event_id)
|
||||
.ok_or_else(|| {
|
||||
MergeRequestError::NotReady(format!(
|
||||
"Merge Request `{merge_request_id}` approval is no longer effective"
|
||||
))
|
||||
})?;
|
||||
if review.decision != ReviewDecision::Approve
|
||||
|| review.ticket_item_revision != input.item_revision
|
||||
{
|
||||
return Err(MergeRequestError::NotReady(format!(
|
||||
"Merge Request `{merge_request_id}` lacks approval bound to the current Ticket revision"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let updated = transaction.execute(
|
||||
"UPDATE typed_tickets
|
||||
SET workflow_state='done',workflow_state_explicit=1,updated_at=?3
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
|
||||
params![
|
||||
input.auth.workspace_id,
|
||||
input.ticket_id,
|
||||
input.now.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
if updated != 1 {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket state changed during completion".into(),
|
||||
));
|
||||
}
|
||||
let released = transaction.execute(
|
||||
"DELETE FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
|
||||
params![
|
||||
input.auth.workspace_id,
|
||||
input.ticket_id,
|
||||
input.auth.assignment_id
|
||||
],
|
||||
)?;
|
||||
if released != 1 {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
"Ticket completion assignment changed while releasing it".into(),
|
||||
));
|
||||
}
|
||||
let event = TicketCompletionEvent {
|
||||
operation_id: input.operation_id,
|
||||
ticket_id: input.ticket_id.clone(),
|
||||
item_revision: input.item_revision,
|
||||
merge_request_ids: input.merge_request_ids,
|
||||
completed_by: input.auth.actor(),
|
||||
created_at: input.now,
|
||||
};
|
||||
ticket_completion_event(&transaction, &input.auth.workspace_id, &event)?;
|
||||
transaction.commit()?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub fn repair_selector_from(
|
||||
&self,
|
||||
i: RepairSelectorFrom,
|
||||
) -> Result<MergeRequest, MergeRequestError> {
|
||||
nonempty("selector_from", &i.selector_from)?;
|
||||
nonempty("resolved_subject_ref", &i.resolved_subject_ref)?;
|
||||
let mr = self.get(&i.workspace_id, &i.ticket_id)?;
|
||||
let mr = self.get_for_operation(&i.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
if mr.selector_from.is_some() {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"selector_from is immutable after it is set".into(),
|
||||
@@ -1011,7 +1230,7 @@ impl MergeRequestStore {
|
||||
)?;
|
||||
t.commit()?;
|
||||
drop(c);
|
||||
self.get(&i.workspace_id, &i.ticket_id)
|
||||
self.get_by_id(&i.workspace_id, &i.merge_request_id)
|
||||
}
|
||||
pub fn get(&self, ws: &str, ticket: &str) -> Result<MergeRequest, MergeRequestError> {
|
||||
let c = self.lock()?;
|
||||
@@ -1021,6 +1240,21 @@ impl MergeRequestStore {
|
||||
None => Err(MergeRequestError::NotFound),
|
||||
}
|
||||
}
|
||||
fn get_for_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
merge_request_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<MergeRequest, MergeRequestError> {
|
||||
let mr = self.get_by_id(workspace_id, merge_request_id)?;
|
||||
if !mr.ticket_ids.iter().any(|id| id == ticket_id) {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
"Merge Request is not linked to the addressed Ticket".into(),
|
||||
));
|
||||
}
|
||||
Ok(mr)
|
||||
}
|
||||
|
||||
pub fn get_by_id(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1029,6 +1263,23 @@ impl MergeRequestStore {
|
||||
let c = self.lock()?;
|
||||
load_mr(&c, workspace_id, merge_request_id)?.ok_or(MergeRequestError::NotFound)
|
||||
}
|
||||
pub fn list_for_ticket(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<MergeRequest>, MergeRequestError> {
|
||||
Ok(self
|
||||
.list(
|
||||
workspace_id,
|
||||
&MergeRequestListQuery {
|
||||
ticket_id: Some(ticket_id.to_string()),
|
||||
limit: 100,
|
||||
..Default::default()
|
||||
},
|
||||
)?
|
||||
.items)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1125,6 +1376,24 @@ impl MergeRequestStore {
|
||||
limit.clamp(1, 200),
|
||||
)
|
||||
}
|
||||
fn require_current_ticket_revision(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), MergeRequestError> {
|
||||
nonempty("ticket_item_revision", expected)?;
|
||||
let connection = self.lock()?;
|
||||
let current = current_ticket_revision(&connection, workspace_id, ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current != expected {
|
||||
return Err(MergeRequestError::Conflict(format!(
|
||||
"Ticket item revision changed from `{expected}` to `{current}`; review the current requirements"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
|
||||
self.repo(a, r)?;
|
||||
let x = self
|
||||
@@ -1248,29 +1517,39 @@ fn insert_event<T: Serialize>(
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn load_mr_for_ticket(
|
||||
fn current_ticket_revision(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<MergeRequest>, MergeRequestError> {
|
||||
let merge_request_id: Option<String> = connection
|
||||
) -> Result<Option<String>, MergeRequestError> {
|
||||
let event_revision = connection
|
||||
.query_row(
|
||||
"SELECT rel.merge_request_id
|
||||
FROM merge_request_ticket_relations rel
|
||||
JOIN merge_requests mr
|
||||
ON mr.workspace_id=rel.workspace_id
|
||||
AND mr.merge_request_id=rel.merge_request_id
|
||||
WHERE rel.workspace_id=?1 AND rel.ticket_id=?2
|
||||
ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC
|
||||
"SELECT attribute.value
|
||||
FROM typed_ticket_events event
|
||||
JOIN typed_ticket_event_attributes attribute
|
||||
ON attribute.workspace_id=event.workspace_id
|
||||
AND attribute.ticket_id=event.ticket_id
|
||||
AND attribute.event_index=event.event_index
|
||||
AND attribute.key='event_id'
|
||||
WHERE event.workspace_id=?1 AND event.ticket_id=?2
|
||||
AND event.kind IN ('create','item_edit')
|
||||
ORDER BY event.event_index DESC
|
||||
LIMIT 1",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
match merge_request_id {
|
||||
Some(id) => load_mr(connection, workspace_id, &id),
|
||||
None => Ok(None),
|
||||
if event_revision.is_some() {
|
||||
return Ok(event_revision);
|
||||
}
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
|
||||
@@ -1341,9 +1620,10 @@ fn ticket_event(
|
||||
) -> Result<(), MergeRequestError> {
|
||||
let ticket = &mr.ticket_ids[0];
|
||||
let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?;
|
||||
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Merge Request completed',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Approved source ref `{}` completed implementation.",e.approved_source_ref)])?;
|
||||
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?;
|
||||
for (k, v) in [
|
||||
("implementation_assignment_id", a),
|
||||
("merge_request_id", &mr.merge_request_id),
|
||||
("approval_event_id", &e.approval_event_id),
|
||||
("approved_source_ref", &e.approved_source_ref),
|
||||
("operation_id", &e.operation_id),
|
||||
@@ -1356,6 +1636,57 @@ fn ticket_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ticket_completion_event(
|
||||
transaction: &Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
event: &TicketCompletionEvent,
|
||||
) -> Result<(), MergeRequestError> {
|
||||
let event_index: i64 = transaction.query_row(
|
||||
"SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, event.ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)
|
||||
VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Ticket requirements completed',?6)",
|
||||
params![
|
||||
workspace_id,
|
||||
event.ticket_id,
|
||||
event_index,
|
||||
format!(
|
||||
"worker:{}:{}",
|
||||
event.completed_by.runtime_id, event.completed_by.worker_id
|
||||
),
|
||||
event.created_at.to_rfc3339(),
|
||||
format!(
|
||||
"Current Ticket revision `{}` was approved and completed with Merge Requests: {}.",
|
||||
event.item_revision,
|
||||
event.merge_request_ids.join(", ")
|
||||
)
|
||||
],
|
||||
)?;
|
||||
for (key, value) in [
|
||||
("operation_id", event.operation_id.clone()),
|
||||
("item_revision", event.item_revision.clone()),
|
||||
(
|
||||
"merge_request_ids",
|
||||
serde_json::to_string(&event.merge_request_ids)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
|
||||
),
|
||||
(
|
||||
"ticket_completion",
|
||||
serde_json::to_string(event)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
|
||||
),
|
||||
] {
|
||||
transaction.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)",
|
||||
params![workspace_id, event.ticket_id, event_index, key, value],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
||||
match schema_state(c)? {
|
||||
SchemaState::Fresh => fresh(c),
|
||||
|
||||
@@ -12,7 +12,7 @@ impl AssignmentSource for Assignments {
|
||||
struct Repositories;
|
||||
impl RepositorySource for Repositories {
|
||||
fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result<bool, String> {
|
||||
Ok(w == "W" && r == "R")
|
||||
Ok(w == "W" && matches!(r, "R" | "R2"))
|
||||
}
|
||||
}
|
||||
fn at(s: u32) -> chrono::DateTime<Utc> {
|
||||
@@ -29,11 +29,17 @@ fn auth() -> MergeRequestAuth {
|
||||
assignment_id: "A".into(),
|
||||
}
|
||||
}
|
||||
fn auth_repo(repository_id: &str) -> MergeRequestAuth {
|
||||
MergeRequestAuth {
|
||||
repository_id: repository_id.into(),
|
||||
..auth()
|
||||
}
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
let c = Connection::open(&p).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap();
|
||||
drop(c);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
assignment_id: "A".into(),
|
||||
@@ -44,20 +50,33 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
|
||||
(d, s)
|
||||
}
|
||||
fn open(s: &MergeRequestStore) {
|
||||
fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) {
|
||||
s.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_id: "T".into(),
|
||||
repository_id: "R".into(),
|
||||
selector_from: "work/t".into(),
|
||||
repository_id: repository_id.into(),
|
||||
selector_from: if merge_request_id == "MR" {
|
||||
"work/t".into()
|
||||
} else {
|
||||
format!("work/{merge_request_id}")
|
||||
},
|
||||
selector_to: "develop".into(),
|
||||
summary: "summary".into(),
|
||||
auth: auth(),
|
||||
auth: auth_repo(repository_id),
|
||||
now: at(1),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent {
|
||||
fn open(s: &MergeRequestStore) {
|
||||
open_for(s, "MR", "R");
|
||||
}
|
||||
fn request_for(
|
||||
s: &MergeRequestStore,
|
||||
merge_request_id: &str,
|
||||
repository_id: &str,
|
||||
subject: &str,
|
||||
token: &str,
|
||||
) -> ReviewRequestedEvent {
|
||||
s.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: "W".into(),
|
||||
parent_runtime_id: "runtime".into(),
|
||||
@@ -68,19 +87,31 @@ fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequested
|
||||
})
|
||||
.unwrap();
|
||||
s.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_id: "T".into(),
|
||||
subject_ref: subject.into(),
|
||||
child_session_id: format!("child-{token}"),
|
||||
capability_token: token.into(),
|
||||
auth: auth(),
|
||||
auth: auth_repo(repository_id),
|
||||
now: at(3),
|
||||
})
|
||||
.unwrap()
|
||||
.request_event
|
||||
}
|
||||
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||
request(s, subject, token);
|
||||
fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent {
|
||||
request_for(s, "MR", "R", subject, token)
|
||||
}
|
||||
fn approve_for(
|
||||
s: &MergeRequestStore,
|
||||
merge_request_id: &str,
|
||||
repository_id: &str,
|
||||
subject: &str,
|
||||
token: &str,
|
||||
) -> ReviewEvent {
|
||||
request_for(s, merge_request_id, repository_id, subject, token);
|
||||
s.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: subject.into(),
|
||||
capability_token: token.into(),
|
||||
@@ -91,6 +122,9 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||
approve_for(s, "MR", "R", subject, token)
|
||||
}
|
||||
#[test]
|
||||
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
||||
let (_d, store) = fixture();
|
||||
@@ -98,13 +132,15 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects()
|
||||
request(&store, "published-source", "valid-token");
|
||||
|
||||
let invalid = store
|
||||
.authorize_review_submission("T", "invalid-token")
|
||||
.authorize_review_submission("MR", "invalid-token")
|
||||
.unwrap_err();
|
||||
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
||||
let authorized = store
|
||||
.authorize_review_submission("T", "valid-token")
|
||||
.authorize_review_submission("MR", "valid-token")
|
||||
.unwrap();
|
||||
assert_eq!(authorized.workspace_id, "W");
|
||||
assert_eq!(authorized.merge_request_id, "MR");
|
||||
assert_eq!(authorized.ticket_id, "T");
|
||||
assert_eq!(authorized.subject_ref, "published-source");
|
||||
}
|
||||
|
||||
@@ -115,6 +151,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
let review = approve(&s, "opaque-source-ref", "token");
|
||||
let ready = s
|
||||
.readiness(ReadinessCheck {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("opaque-source-ref".into()),
|
||||
auth: auth(),
|
||||
@@ -123,6 +160,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
assert!(ready.ready);
|
||||
let merged = s
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: review.event_id,
|
||||
@@ -150,9 +188,10 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!current_assignment);
|
||||
assert!(current_assignment);
|
||||
let replayed = s
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: merged.approval_event_id.clone(),
|
||||
@@ -166,6 +205,41 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(replayed, merged);
|
||||
let completed = s
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-op".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
auth: auth(),
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(completed.merge_request_ids, vec!["MR"]);
|
||||
let connection = Connection::open(d.path().join("db")).unwrap();
|
||||
let (state, assignment_exists): (String, bool) = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state, EXISTS(
|
||||
SELECT 1 FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id='W' AND ticket_id='T'
|
||||
) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, "done");
|
||||
assert!(!assignment_exists);
|
||||
let replayed_completion = s
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-op".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(replayed_completion, completed);
|
||||
let json = serde_json::to_string(&mr).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
@@ -187,6 +261,7 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
|
||||
request(&s, "source-b", "two");
|
||||
assert!(
|
||||
s.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source-c".into(),
|
||||
capability_token: "two".into(),
|
||||
@@ -221,6 +296,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o
|
||||
|
||||
let stale = s
|
||||
.readiness(ReadinessCheck {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
@@ -244,6 +320,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o
|
||||
let second = approve(&s, "source-2", "two");
|
||||
let ready = s
|
||||
.readiness(ReadinessCheck {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
@@ -260,6 +337,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o
|
||||
// integration evidence for the current target pair.
|
||||
let merged = s
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
operation_id: "target-moved".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source-2".into(),
|
||||
@@ -283,6 +361,7 @@ fn review_revocation_invalidates_readiness() {
|
||||
open(&s);
|
||||
let review = approve(&s, "source", "one");
|
||||
s.revoke_review(RevokeMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
review_event_id: review.event_id,
|
||||
reason: "bad evidence".into(),
|
||||
@@ -292,6 +371,7 @@ fn review_revocation_invalidates_readiness() {
|
||||
.unwrap();
|
||||
let r = s
|
||||
.readiness(ReadinessCheck {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source".into()),
|
||||
auth: auth(),
|
||||
@@ -366,6 +446,7 @@ fn completion_rejects_superseded_approval_for_same_subject() {
|
||||
request(&store, "subject", "changes");
|
||||
store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "subject".into(),
|
||||
capability_token: "changes".into(),
|
||||
@@ -376,6 +457,7 @@ fn completion_rejects_superseded_approval_for_same_subject() {
|
||||
})
|
||||
.unwrap();
|
||||
let result = store.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: old_approval.event_id,
|
||||
@@ -398,6 +480,7 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() {
|
||||
request(&store, "other-subject", "pending");
|
||||
store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: approval.event_id,
|
||||
@@ -411,6 +494,7 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() {
|
||||
})
|
||||
.unwrap();
|
||||
let late = store.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "other-subject".into(),
|
||||
capability_token: "pending".into(),
|
||||
@@ -436,6 +520,7 @@ fn selector_repair_requires_and_accepts_an_approved_resolved_subject() {
|
||||
.unwrap();
|
||||
let repaired = store
|
||||
.repair_selector_from(RepairSelectorFrom {
|
||||
merge_request_id: "MR".into(),
|
||||
workspace_id: "W".into(),
|
||||
ticket_id: "T".into(),
|
||||
selector_from: "restored-work".into(),
|
||||
@@ -460,6 +545,7 @@ fn selector_repair_rejects_unapproved_resolved_subject() {
|
||||
.execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", [])
|
||||
.unwrap();
|
||||
let result = store.repair_selector_from(RepairSelectorFrom {
|
||||
merge_request_id: "MR".into(),
|
||||
workspace_id: "W".into(),
|
||||
ticket_id: "T".into(),
|
||||
selector_from: "wrong-work".into(),
|
||||
@@ -552,6 +638,7 @@ fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
||||
.execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", [])
|
||||
.unwrap();
|
||||
let result = store.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: approval.event_id,
|
||||
@@ -574,3 +661,158 @@ fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
||||
.unwrap();
|
||||
assert_eq!(state, "inprogress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_can_link_parallel_open_merge_requests_in_distinct_repositories() {
|
||||
let (_dir, store) = fixture();
|
||||
open_for(&store, "MR", "R");
|
||||
open_for(&store, "MR-2", "R2");
|
||||
|
||||
let linked = store.list_for_ticket("W", "T").unwrap();
|
||||
assert_eq!(
|
||||
linked
|
||||
.iter()
|
||||
.map(|request| request.merge_request_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["MR", "MR-2"]
|
||||
);
|
||||
let duplicate = store.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: "MR-duplicate".into(),
|
||||
ticket_id: "T".into(),
|
||||
repository_id: "R2".into(),
|
||||
selector_from: "work/duplicate".into(),
|
||||
selector_to: "develop".into(),
|
||||
summary: String::new(),
|
||||
auth: auth_repo("R2"),
|
||||
now: at(2),
|
||||
});
|
||||
assert!(matches!(duplicate, Err(MergeRequestError::Conflict(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mr_operations_and_review_capabilities_are_bound_to_explicit_identity() {
|
||||
let (_dir, store) = fixture();
|
||||
open_for(&store, "MR", "R");
|
||||
open_for(&store, "MR-2", "R2");
|
||||
request_for(&store, "MR", "R", "subject-one", "token-one");
|
||||
|
||||
assert!(matches!(
|
||||
store.authorize_review_submission("MR-2", "token-one"),
|
||||
Err(MergeRequestError::Unauthorized(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR-2".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "subject-one".into(),
|
||||
capability_token: "token-one".into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "wrong MR".into(),
|
||||
findings: vec![],
|
||||
now: at(4),
|
||||
}),
|
||||
Err(MergeRequestError::Unauthorized(_))
|
||||
));
|
||||
assert!(
|
||||
!store
|
||||
.get_by_id("W", "MR-2")
|
||||
.unwrap()
|
||||
.thread
|
||||
.iter()
|
||||
.any(|event| matches!(event, MergeRequestThreadEvent::Review(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() {
|
||||
let (dir, store) = fixture();
|
||||
open_for(&store, "MR", "R");
|
||||
open_for(&store, "MR-2", "R2");
|
||||
let first = approve_for(&store, "MR", "R", "subject-one", "token-one");
|
||||
let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two");
|
||||
|
||||
store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-one".into(),
|
||||
approval_event_id: first.event_id,
|
||||
current_subject_ref: "subject-one".into(),
|
||||
target_ref_before: "target-one-before".into(),
|
||||
target_ref_after: "target-one-after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth_repo("R"),
|
||||
now: at(5),
|
||||
})
|
||||
.unwrap();
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
let (state, assigned): (String, bool) = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, "inprogress");
|
||||
assert!(assigned);
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
auth: auth(),
|
||||
now: at(6),
|
||||
}),
|
||||
Err(MergeRequestError::NotReady(_))
|
||||
));
|
||||
|
||||
store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR-2".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-two".into(),
|
||||
approval_event_id: second.event_id,
|
||||
current_subject_ref: "subject-two".into(),
|
||||
target_ref_before: "target-two-before".into(),
|
||||
target_ref_after: "target-two-after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth_repo("R2"),
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "stale".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
}),
|
||||
Err(MergeRequestError::Conflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
}),
|
||||
Err(MergeRequestError::Conflict(_))
|
||||
));
|
||||
store
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR-2".into(), "MR".into()],
|
||||
auth: auth(),
|
||||
now: at(9),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ impl_openapi_schema!(
|
||||
CancelTicketImplementationRequest,
|
||||
ClearTicketRoleAssignmentQuery,
|
||||
CompleteMergeRequestRequest,
|
||||
CompleteTicketRequest,
|
||||
CreateTicketOrchestrationPlanRequest,
|
||||
CreateTicketRecordRequest,
|
||||
CreateTicketRelationRequest,
|
||||
@@ -279,6 +280,7 @@ impl_openapi_schema!(
|
||||
SubmitMergeRequestReviewRequest,
|
||||
TextResponse,
|
||||
TicketCloseRecordRequest,
|
||||
TicketCompletionEvent,
|
||||
TicketDependencyCheckResponse,
|
||||
TicketDetail,
|
||||
TicketDoctorResponse,
|
||||
@@ -2616,20 +2618,20 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: OpenMergeRequestRequest,
|
||||
) -> Result<PublicMergeRequest, RepositoryApiError>;
|
||||
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[get("/api/w/{workspace_id}/merge-requests/{id}/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn merge_request_readiness(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
) -> Result<MergeRequestReadinessResponse, RepositoryApiError>;
|
||||
#[get("/api/w/{workspace_id}/tickets/{id}/merge-request/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[get("/api/w/{workspace_id}/merge-requests/{id}/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn merge_request_thread(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[query] query: MergeRequestThreadQuery,
|
||||
) -> Result<MergeRequestThreadResponse, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/merge-requests/{id}/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)]
|
||||
async fn merge_request_selector_repair(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2644,7 +2646,7 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: RegisterReviewerChildSessionRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
#[post("/api/w/{workspace_id}/merge-requests/{id}/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
async fn merge_request_review_capability_register(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2652,14 +2654,14 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: RegisterMergeRequestReviewCapabilityRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
#[post("/api/w/{workspace_id}/merge-requests/{id}/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
async fn merge_request_review_submit(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: SubmitMergeRequestReviewRequest,
|
||||
) -> Result<ReviewEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
#[post("/api/w/{workspace_id}/merge-requests/{id}/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
async fn merge_request_review_revoke(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2667,7 +2669,7 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: RevokeMergeRequestReviewRequest,
|
||||
) -> Result<ReviewRevokedEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/merge-request/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
#[post("/api/w/{workspace_id}/merge-requests/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
async fn merge_request_complete(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2675,6 +2677,14 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: CompleteMergeRequestRequest,
|
||||
) -> Result<MergeEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
async fn ticket_complete(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: CompleteTicketRequest,
|
||||
) -> Result<TicketCompletionEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_close_record(
|
||||
&self,
|
||||
@@ -5881,6 +5891,7 @@ pub struct TicketQueryItem {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub unresolved_review_count: usize,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
@@ -5939,6 +5950,7 @@ pub struct TicketDetail {
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
pub assignment_diagnostics: Vec<String>,
|
||||
pub action_eligibility: TicketActionEligibility,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub resolution: Option<String>,
|
||||
@@ -6297,12 +6309,14 @@ macro_rules! merge_request_event {
|
||||
|
||||
merge_request_event!(ReviewRequestedEvent {
|
||||
subject_ref: String,
|
||||
ticket_item_revision: String,
|
||||
requested_by: MergeRequestWorkerIdentity,
|
||||
reviewer: MergeRequestWorkerIdentity,
|
||||
});
|
||||
merge_request_event!(ReviewEvent {
|
||||
request_event_id: String,
|
||||
subject_ref: String,
|
||||
ticket_item_revision: String,
|
||||
decision: ReviewDecision,
|
||||
body: String,
|
||||
findings: Vec<ReviewFinding>,
|
||||
@@ -6513,6 +6527,25 @@ pub struct CompleteMergeRequestRequest {
|
||||
pub resolution: ConflictResolution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CompleteTicketRequest {
|
||||
pub operation_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
pub ticket_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub completed_by: MergeRequestWorkerIdentity,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeManagementApiError {
|
||||
@@ -9630,7 +9663,7 @@ mod tests {
|
||||
.iter()
|
||||
.filter(|operation| operation.operation_id.starts_with("ticket_"))
|
||||
.count(),
|
||||
37
|
||||
38
|
||||
);
|
||||
assert_eq!(
|
||||
operations
|
||||
@@ -9660,7 +9693,7 @@ mod tests {
|
||||
(
|
||||
"merge_request_complete",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/complete",
|
||||
"/api/w/{workspace_id}/merge-requests/{id}/complete",
|
||||
),
|
||||
(
|
||||
"objective_ticket_unlink",
|
||||
@@ -11292,6 +11325,7 @@ mod openapi_artifact_tests {
|
||||
"merge_request_review_revoke",
|
||||
"merge_request_review_submit",
|
||||
"merge_request_reviewer_child_register",
|
||||
"ticket_complete",
|
||||
"runtime_resource_fetch",
|
||||
"worker_control_cancel",
|
||||
"worker_control_input",
|
||||
|
||||
+44
-41
@@ -85,7 +85,7 @@ pub enum TicketError {
|
||||
},
|
||||
#[error("ticket target repository `{0}` appears more than once")]
|
||||
DuplicateTargetRepository(String),
|
||||
#[error("ticket requires exactly one read_write target, found {0}")]
|
||||
#[error("ticket requires at least one read_write target, found {0}")]
|
||||
InvalidReadWriteTargetCount(usize),
|
||||
#[error("ticket target authority is unavailable")]
|
||||
TargetAuthorityUnavailable,
|
||||
@@ -649,7 +649,7 @@ fn resolve_ready_targets(
|
||||
.iter()
|
||||
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||
.count();
|
||||
if read_write_count != 1 {
|
||||
if read_write_count == 0 {
|
||||
return Err(TicketError::InvalidReadWriteTargetCount(read_write_count));
|
||||
}
|
||||
let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?;
|
||||
@@ -6009,45 +6009,48 @@ mod tests {
|
||||
Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main"
|
||||
));
|
||||
|
||||
for (name, targets, expected_count) in [
|
||||
(
|
||||
"No write target",
|
||||
vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)],
|
||||
0,
|
||||
),
|
||||
(
|
||||
"Multiple write targets",
|
||||
vec![write_target("main"), write_target("docs")],
|
||||
2,
|
||||
),
|
||||
] {
|
||||
let mut input = NewTicket::new(name);
|
||||
input.targets = targets;
|
||||
let ticket = backend.create(input).unwrap();
|
||||
let error = backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ready-{expected_count}"),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
TicketError::InvalidReadWriteTargetCount(count) if count == expected_count
|
||||
));
|
||||
let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(
|
||||
unchanged
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| !event.attributes.contains_key("operation_key"))
|
||||
);
|
||||
}
|
||||
let mut no_write = NewTicket::new("No write target");
|
||||
no_write.targets = vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)];
|
||||
let ticket = backend.create(no_write).unwrap();
|
||||
let error = backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready-no-write".into(),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, TicketError::InvalidReadWriteTargetCount(0)));
|
||||
let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
|
||||
let mut multiple_writes = NewTicket::new("Multiple write targets");
|
||||
multiple_writes.targets = vec![write_target("main"), write_target("docs")];
|
||||
let ticket = backend.create(multiple_writes).unwrap();
|
||||
let ready = backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(ticket.id),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready-multiple-writes".into(),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(
|
||||
ready
|
||||
.meta
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -134,7 +134,7 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
|
||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
||||
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
|
||||
The backend atomically validates and normalizes every persisted repository target, requires exactly one \
|
||||
The backend atomically validates and normalizes every persisted repository target, requires at least one \
|
||||
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
|
||||
The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \
|
||||
|
||||
@@ -28,12 +28,13 @@ fn workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
.expect("static Merge Request workflow instruction declaration is valid")
|
||||
}
|
||||
|
||||
const ALL_KINDS: [Kind; 5] = [
|
||||
const ALL_KINDS: [Kind; 6] = [
|
||||
Kind::Show,
|
||||
Kind::Open,
|
||||
Kind::Review,
|
||||
Kind::Readiness,
|
||||
Kind::Complete,
|
||||
Kind::CompleteTicket,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -42,6 +43,7 @@ enum Kind {
|
||||
Readiness,
|
||||
Open,
|
||||
Complete,
|
||||
CompleteTicket,
|
||||
Review,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
@@ -50,8 +52,8 @@ struct MergeRequestTool {
|
||||
kind: Kind,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct TicketInput {
|
||||
ticket: String,
|
||||
struct MergeRequestInput {
|
||||
merge_request_id: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct OpenMergeRequestInput {
|
||||
@@ -64,7 +66,7 @@ struct OpenMergeRequestInput {
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct CompleteMergeRequestInput {
|
||||
ticket: String,
|
||||
merge_request_id: String,
|
||||
operation_id: String,
|
||||
approval_event_id: String,
|
||||
target_ref_before: String,
|
||||
@@ -73,6 +75,13 @@ struct CompleteMergeRequestInput {
|
||||
resolution: MergeResolutionInput,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct CompleteTicketInput {
|
||||
ticket: String,
|
||||
operation_id: String,
|
||||
item_revision: String,
|
||||
merge_request_ids: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum MergeStrategyInput {
|
||||
FastForward,
|
||||
@@ -110,16 +119,6 @@ struct ReviewFindingInput {
|
||||
line: Option<u32>,
|
||||
body: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketMergeRequestProjection {
|
||||
merge_request: Option<TicketMergeRequestReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketMergeRequestReference {
|
||||
merge_request_id: String,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
|
||||
match self {
|
||||
@@ -127,7 +126,7 @@ impl Kind {
|
||||
Self::Open => config.open,
|
||||
Self::Review => config.review,
|
||||
Self::Readiness => config.readiness_check,
|
||||
Self::Complete => config.complete,
|
||||
Self::Complete | Self::CompleteTicket => config.complete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,14 +136,16 @@ impl Kind {
|
||||
Self::Readiness => "CheckMergeRequestReadiness",
|
||||
Self::Open => "OpenMergeRequest",
|
||||
Self::Complete => "CompleteMergeRequest",
|
||||
Self::CompleteTicket => "CompleteTicket",
|
||||
Self::Review => "ReviewMergeRequest",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)),
|
||||
Self::Show | Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
|
||||
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
|
||||
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||
}
|
||||
}
|
||||
@@ -156,17 +157,20 @@ impl Tool for MergeRequestTool {
|
||||
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||
})?;
|
||||
if matches!(self.kind, Kind::Show) {
|
||||
let value: TicketInput = parse(input)?;
|
||||
nonempty(&value.ticket)?;
|
||||
return self.show_current_merge_request(ws, &value.ticket);
|
||||
let value: MergeRequestInput = parse(input)?;
|
||||
nonempty_named("merge_request_id", &value.merge_request_id)?;
|
||||
return self.show_merge_request(ws, &value.merge_request_id);
|
||||
}
|
||||
let (method, path, body) = match self.kind {
|
||||
Kind::Readiness => {
|
||||
let v: TicketInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
let v: MergeRequestInput = parse(input)?;
|
||||
nonempty_named("merge_request_id", &v.merge_request_id)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("/api/w/{ws}/tickets/{}/merge-request/readiness", v.ticket),
|
||||
format!(
|
||||
"/api/w/{ws}/merge-requests/{}/readiness",
|
||||
encode_path_segment(&v.merge_request_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -184,15 +188,34 @@ impl Tool for MergeRequestTool {
|
||||
}
|
||||
Kind::Complete => {
|
||||
let v: CompleteMergeRequestInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
nonempty_named("merge_request_id", &v.merge_request_id)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket),
|
||||
format!(
|
||||
"/api/w/{ws}/merge-requests/{}/complete",
|
||||
encode_path_segment(&v.merge_request_id)
|
||||
),
|
||||
Some(
|
||||
json!({"operation_id":v.operation_id,"approval_event_id":v.approval_event_id,"target_ref_before":v.target_ref_before,"target_ref_after":v.target_ref_after,"strategy":match v.strategy{MergeStrategyInput::FastForward=>"fast_forward",MergeStrategyInput::Merge=>"merge"},"resolution":match v.resolution{MergeResolutionInput::None=>"none",MergeResolutionInput::Clean=>"clean",MergeResolutionInput::ConflictsResolved=>"conflicts_resolved"}}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Kind::CompleteTicket => {
|
||||
let v: CompleteTicketInput = parse(input)?;
|
||||
nonempty_named("ticket", &v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{ws}/tickets/{}/complete",
|
||||
encode_path_segment(&v.ticket)
|
||||
),
|
||||
Some(json!({
|
||||
"operation_id": v.operation_id,
|
||||
"item_revision": v.item_revision,
|
||||
"merge_request_ids": v.merge_request_ids,
|
||||
})),
|
||||
)
|
||||
}
|
||||
Kind::Review => {
|
||||
let v: ReviewMergeRequestInput = parse(input)?;
|
||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||
@@ -203,8 +226,8 @@ impl Tool for MergeRequestTool {
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{ws}/tickets/{}/merge-request/reviews",
|
||||
ctx.ticket_id
|
||||
"/api/w/{ws}/merge-requests/{}/reviews",
|
||||
encode_path_segment(&ctx.merge_request_id)
|
||||
),
|
||||
Some(
|
||||
json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::<Vec<_>>() }),
|
||||
@@ -235,35 +258,12 @@ impl Tool for MergeRequestTool {
|
||||
}
|
||||
|
||||
impl MergeRequestTool {
|
||||
fn show_current_merge_request(
|
||||
fn show_merge_request(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket: &str,
|
||||
merge_request_id: &str,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let ticket_path = encode_path_segment(ticket);
|
||||
let show_response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/tickets/{ticket_path}/show"),
|
||||
json!({"event_limit": 1}).to_string(),
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !show_response.is_success() {
|
||||
return Err(api_error("Ticket Show API", &show_response));
|
||||
}
|
||||
let projection: TicketMergeRequestProjection = serde_json::from_str(&show_response.body)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Ticket Show API returned a malformed Merge Request projection: {error}"
|
||||
))
|
||||
})?;
|
||||
let merge_request = projection.merge_request.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!("Ticket `{ticket}` has no current Merge Request"))
|
||||
})?;
|
||||
nonempty_id("merge_request_id", &merge_request.merge_request_id)?;
|
||||
|
||||
let merge_request_id = encode_path_segment(&merge_request.merge_request_id);
|
||||
let merge_request_id = encode_path_segment(merge_request_id);
|
||||
let response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest::get(format!(
|
||||
@@ -313,10 +313,10 @@ fn encode_path_segment(value: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
fn nonempty_id(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
fn nonempty_named(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() || value.chars().any(char::is_control) {
|
||||
Err(ToolError::ExecutionFailed(format!(
|
||||
"Ticket Show API returned an invalid {name}"
|
||||
Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -404,10 +404,13 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
||||
),
|
||||
"OpenMergeRequest" => Some(
|
||||
"Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.",
|
||||
"Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.",
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
),
|
||||
"CompleteTicket" => Some(
|
||||
"Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this releases the current assignment atomically.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
@@ -478,6 +481,7 @@ mod tests {
|
||||
"CheckMergeRequestReadiness",
|
||||
"OpenMergeRequest",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
"ReviewMergeRequest",
|
||||
] {
|
||||
assert!(description(name).is_some(), "missing operation {name}");
|
||||
@@ -497,18 +501,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({"merge_request":{"merge_request_id":"MR/1"}})),
|
||||
response(json!({"merge_request_id":"MR/1","state":"open"})),
|
||||
]));
|
||||
async fn show_reads_explicit_merge_request_resource() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
||||
json!({"merge_request_id":"MR/1","state":"open"}),
|
||||
)]));
|
||||
let tool = MergeRequestTool {
|
||||
kind: Kind::Show,
|
||||
client: client.clone(),
|
||||
};
|
||||
|
||||
let output = tool
|
||||
.execute(r#"{"ticket":"T/1"}"#, ToolExecutionContext::default())
|
||||
.execute(
|
||||
r#"{"merge_request_id":"MR/1"}"#,
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.expect("show should succeed");
|
||||
|
||||
@@ -517,18 +523,14 @@ mod tests {
|
||||
Some(r#"{"merge_request_id":"MR/1","state":"open"}"#)
|
||||
);
|
||||
let requests = client.requests.lock().expect("request lock");
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
|
||||
assert_eq!(requests[0].path, "/api/w/ws/tickets/T%2F1/show");
|
||||
assert_eq!(requests[1].method, WorkspaceRequestMethod::Get);
|
||||
assert_eq!(requests[1].path, "/api/w/ws/merge-requests/MR%2F1");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert_eq!(requests[0].path, "/api/w/ws/merge-requests/MR%2F1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_fails_closed_when_ticket_has_no_current_merge_request() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
||||
json!({"merge_request":null}),
|
||||
)]));
|
||||
async fn show_requires_explicit_merge_request_identity() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
||||
let tool = MergeRequestTool {
|
||||
kind: Kind::Show,
|
||||
client: client.clone(),
|
||||
@@ -537,10 +539,10 @@ mod tests {
|
||||
let error = tool
|
||||
.execute(r#"{"ticket":"T1"}"#, ToolExecutionContext::default())
|
||||
.await
|
||||
.expect_err("missing Merge Request must fail");
|
||||
.expect_err("ticket-only lookup must fail");
|
||||
|
||||
assert!(error.to_string().contains("no current Merge Request"));
|
||||
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
||||
assert!(error.to_string().contains("merge_request_id"));
|
||||
assert!(client.requests.lock().expect("request lock").is_empty());
|
||||
}
|
||||
|
||||
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
|
||||
@@ -596,7 +598,8 @@ mod tests {
|
||||
[
|
||||
"ShowMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest"
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket"
|
||||
]
|
||||
);
|
||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||
|
||||
@@ -27,6 +27,7 @@ struct ModelTicketQueryItem {
|
||||
unresolved_blocker_count: usize,
|
||||
unresolved_review_count: usize,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
actions: Option<ModelTicketActions>,
|
||||
@@ -162,6 +164,8 @@ struct ModelEvidenceEvent {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelMergeRequest {
|
||||
merge_request_id: String,
|
||||
repository_key: String,
|
||||
state: String,
|
||||
selector_from: Option<String>,
|
||||
selector_to: String,
|
||||
@@ -251,6 +255,10 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
||||
merge_requests: array_field(item, "merge_requests")?
|
||||
.iter()
|
||||
.map(project_merge_request)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: item
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
@@ -295,6 +303,10 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_requests: array_field(root, "merge_requests")?
|
||||
.iter()
|
||||
.map(project_merge_request)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: root
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
@@ -498,11 +510,13 @@ fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
|
||||
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
let merge = object(value, "Merge Request summary")?;
|
||||
Ok(ModelMergeRequest {
|
||||
merge_request_id: string_field(merge, "merge_request_id")?,
|
||||
repository_key: string_field(merge, "repository_key")?,
|
||||
state: string_field(merge, "state")?,
|
||||
selector_from: optional_string(merge, "selector_from")?,
|
||||
selector_to: string_field(merge, "selector_to")?,
|
||||
review_status: string_field(merge, "review_status")?,
|
||||
subject_ref: optional_string(merge, "subject_ref")?,
|
||||
subject_ref: optional_string(merge, "current_subject_ref")?,
|
||||
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||
})
|
||||
}
|
||||
@@ -721,6 +735,7 @@ mod tests {
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
},
|
||||
"merge_requests": [],
|
||||
"merge_request": null
|
||||
}]
|
||||
})).expect("Ticket query projection");
|
||||
@@ -788,6 +803,7 @@ mod tests {
|
||||
"implementation_reports": [],
|
||||
"assignments": [],
|
||||
"current_coder": null,
|
||||
"merge_requests": [],
|
||||
"merge_request": null,
|
||||
"evidence": {
|
||||
"has_merge_request": false,
|
||||
|
||||
@@ -721,6 +721,7 @@ mod tests {
|
||||
"ReviewMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
] {
|
||||
assert!(combined.contains(operation), "missing {operation}");
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ struct SubWorkerSpawnInput {
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ReviewerHandoffInput {
|
||||
ticket_id: String,
|
||||
merge_request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -369,9 +370,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
||||
let Some(review) = &input.review else {
|
||||
return Ok(());
|
||||
};
|
||||
if review.ticket_id.trim().is_empty() {
|
||||
if review.ticket_id.trim().is_empty() || review.merge_request_id.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"reviewer handoff requires non-empty ticket_id".to_string(),
|
||||
"reviewer handoff requires non-empty ticket_id and merge_request_id".to_string(),
|
||||
));
|
||||
}
|
||||
if input.profile.as_deref() != Some("builtin:reviewer") {
|
||||
@@ -484,6 +485,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
let reviewer_capability = input.review.as_ref().map(|review| {
|
||||
(
|
||||
review.ticket_id.clone(),
|
||||
review.merge_request_id.clone(),
|
||||
format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::now_v7().simple(),
|
||||
@@ -492,7 +494,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)
|
||||
});
|
||||
let child_workspace_context =
|
||||
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
||||
if let Some((ticket_id, merge_request_id, capability_token)) = &reviewer_capability {
|
||||
let workspace_id =
|
||||
self.workspace_context
|
||||
.workspace_id()
|
||||
@@ -513,6 +515,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
parent_client.clone(),
|
||||
ReviewerContext {
|
||||
ticket_id: ticket_id.clone(),
|
||||
merge_request_id: merge_request_id.clone(),
|
||||
},
|
||||
capability_token.clone(),
|
||||
));
|
||||
@@ -610,7 +613,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
child_registry
|
||||
.attach_parent_protocol(session.protocol_sender(), session.session_id_string());
|
||||
|
||||
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
||||
if let Some((_ticket_id, merge_request_id, capability_token)) = &reviewer_capability {
|
||||
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
||||
})?;
|
||||
@@ -646,9 +649,9 @@ impl Tool for SubWorkerSpawnTool {
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{}/tickets/{}/merge-request/review-capabilities",
|
||||
"/api/w/{}/merge-requests/{}/review-capabilities",
|
||||
workspace_id.as_str(),
|
||||
ticket_id
|
||||
merge_request_id
|
||||
),
|
||||
body.to_string(),
|
||||
);
|
||||
@@ -1170,21 +1173,21 @@ mod tests {
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"write"}],
|
||||
"command":true,
|
||||
"review":{"ticket_id":"T1"}
|
||||
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||
"scope":[{"target":"work","permission":"write"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"read"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&read_only).is_err());
|
||||
|
||||
@@ -719,6 +719,7 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewerContext {
|
||||
pub ticket_id: String,
|
||||
pub merge_request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -771,8 +772,9 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
let ticket_id = &self.context.ticket_id;
|
||||
let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query");
|
||||
let ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show");
|
||||
let merge_request_id = &self.context.merge_request_id;
|
||||
let review_path =
|
||||
format!("/api/w/{workspace_id}/tickets/{ticket_id}/merge-request/reviews");
|
||||
format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews");
|
||||
let read_allowed = request.method == WorkspaceRequestMethod::Get
|
||||
|| (request.method == WorkspaceRequestMethod::Post
|
||||
&& (request.path == ticket_query_path || request.path == ticket_show_path));
|
||||
@@ -952,6 +954,7 @@ mod reviewer_client_tests {
|
||||
inner,
|
||||
ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
},
|
||||
"secret".into(),
|
||||
)
|
||||
@@ -1015,7 +1018,7 @@ mod reviewer_client_tests {
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/T1/merge-request/reviews",
|
||||
"/api/w/ws/merge-requests/MR1/reviews",
|
||||
r#"{"decision":"approve"}"#.to_string(),
|
||||
))
|
||||
.expect("attested review should be forwarded");
|
||||
|
||||
@@ -2,8 +2,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use merge_request::{
|
||||
MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent,
|
||||
ReviewDecision,
|
||||
MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision,
|
||||
};
|
||||
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
||||
@@ -856,8 +855,8 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let write_repository_key = ticket_write_target_repository_key(&targets);
|
||||
let has_target = write_repository_key.is_some();
|
||||
let write_repository_keys = ticket_write_target_repository_keys(&targets);
|
||||
let has_target = !write_repository_keys.is_empty();
|
||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||
let mut queue_assignment_blockers = Vec::new();
|
||||
for ticket_id in &dependency_check.queue_tickets {
|
||||
@@ -929,8 +928,12 @@ impl SqliteWorkspaceAuthority {
|
||||
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||
blockers: action_blockers,
|
||||
};
|
||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||
Ok(request) => {
|
||||
let merge_requests = self
|
||||
.merge_request_store
|
||||
.list_for_ticket(&self.workspace_id, id)
|
||||
.map_err(|error| Error::Store(error.to_string()))?
|
||||
.into_iter()
|
||||
.map(|request| {
|
||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||
self.merge_revision_source
|
||||
.resolve_subject_ref(&request.repository_id, selector)
|
||||
@@ -940,17 +943,16 @@ impl SqliteWorkspaceAuthority {
|
||||
.get_repository(&self.workspace_id, &request.repository_id)?
|
||||
.map(|repository| repository.repository_key)
|
||||
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
|
||||
Some(merge_request_summary(
|
||||
Ok(merge_request_summary(
|
||||
request,
|
||||
repository_key,
|
||||
current_subject_ref,
|
||||
))
|
||||
}
|
||||
Err(MergeRequestError::NotFound) => None,
|
||||
Err(error) => return Err(Error::Store(error.to_string())),
|
||||
};
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone());
|
||||
let evidence =
|
||||
ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref());
|
||||
ticket_evidence_summary(&write_repository_keys, &ticket.events, &merge_requests);
|
||||
let item_revision = ticket
|
||||
.events
|
||||
.iter()
|
||||
@@ -1031,6 +1033,7 @@ impl SqliteWorkspaceAuthority {
|
||||
current_coder,
|
||||
assignment_diagnostics,
|
||||
action_eligibility,
|
||||
merge_requests,
|
||||
merge_request,
|
||||
evidence,
|
||||
resolution: ticket
|
||||
@@ -1850,81 +1853,93 @@ fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> {
|
||||
let mut write_targets = targets
|
||||
fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<&str> {
|
||||
targets
|
||||
.iter()
|
||||
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite);
|
||||
let write_target = write_targets.next()?;
|
||||
write_targets
|
||||
.next()
|
||||
.is_none()
|
||||
.then_some(write_target.repository_key.as_str())
|
||||
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite)
|
||||
.map(|target| target.repository_key.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ticket_evidence_summary(
|
||||
ticket_write_repository_key: Option<&str>,
|
||||
ticket_write_repository_keys: &[&str],
|
||||
events: &[TicketEvent],
|
||||
merge_request: Option<&TicketMergeRequestSummary>,
|
||||
merge_requests: &[TicketMergeRequestSummary],
|
||||
) -> TicketEvidenceSummary {
|
||||
let linked_merge_request = merge_request.filter(|request| {
|
||||
request.state == "open"
|
||||
&& ticket_write_repository_key
|
||||
.is_some_and(|repository_key| repository_key == request.repository_key)
|
||||
});
|
||||
let has_merge_request = linked_merge_request.is_some();
|
||||
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
|
||||
request
|
||||
.current_subject_ref
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
});
|
||||
let has_review_request = linked_merge_request.is_some_and(|request| {
|
||||
request
|
||||
.review_subject_ref
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
});
|
||||
let relevant = merge_requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.state != "closed"
|
||||
&& ticket_write_repository_keys.contains(&request.repository_key.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let has_merge_request = !relevant.is_empty();
|
||||
let has_current_subject_ref = has_merge_request
|
||||
&& relevant.iter().all(|request| {
|
||||
request.state == "merged"
|
||||
|| request
|
||||
.current_subject_ref
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
});
|
||||
let has_review_request = has_merge_request
|
||||
&& relevant.iter().all(|request| {
|
||||
request.state == "merged"
|
||||
|| request
|
||||
.review_subject_ref
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
});
|
||||
let has_commit = has_current_subject_ref;
|
||||
let review_status = linked_merge_request.map(|request| request.review_status.clone());
|
||||
let approved_current_subject = review_status.as_deref() == Some("approved");
|
||||
let unresolved_request_changes = review_status.as_deref() == Some("changes_requested");
|
||||
let unresolved_request_changes = relevant
|
||||
.iter()
|
||||
.any(|request| request.review_status == "changes_requested");
|
||||
let approved_current_subject = has_merge_request
|
||||
&& relevant
|
||||
.iter()
|
||||
.all(|request| request.state == "merged" || request.review_status == "approved");
|
||||
let review_status = if !has_merge_request {
|
||||
None
|
||||
} else if unresolved_request_changes {
|
||||
Some("changes_requested".to_string())
|
||||
} else if approved_current_subject {
|
||||
Some("approved".to_string())
|
||||
} else {
|
||||
Some("pending".to_string())
|
||||
};
|
||||
let latest_rescope = events
|
||||
.iter()
|
||||
.filter(|event| substantive_item_edit(event))
|
||||
.last();
|
||||
let review_after_rescope = approved_current_subject
|
||||
&& latest_rescope.is_none_or(|rescope| {
|
||||
let Some(rescope_at) = event_timestamp(rescope) else {
|
||||
return false;
|
||||
};
|
||||
let Some(requested_at) = linked_merge_request
|
||||
.and_then(|request| request.review_requested_at.as_deref())
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(reviewed_at) = linked_merge_request
|
||||
.and_then(|request| request.review_submitted_at.as_deref())
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
requested_at > rescope_at && reviewed_at > rescope_at
|
||||
&& relevant.iter().all(|request| {
|
||||
latest_rescope.is_none_or(|rescope| {
|
||||
let Some(rescope_at) = event_timestamp(rescope) else {
|
||||
return false;
|
||||
};
|
||||
let Some(requested_at) = request
|
||||
.review_requested_at
|
||||
.as_deref()
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(reviewed_at) = request
|
||||
.review_submitted_at
|
||||
.as_deref()
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
requested_at > rescope_at && reviewed_at > rescope_at
|
||||
})
|
||||
});
|
||||
|
||||
let mut missing = Vec::new();
|
||||
match merge_request {
|
||||
None => missing.push("merge_request".to_string()),
|
||||
Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()),
|
||||
Some(request)
|
||||
if ticket_write_repository_key
|
||||
.is_none_or(|repository_key| repository_key != request.repository_key) =>
|
||||
{
|
||||
missing.push("merge_request_repository".to_string())
|
||||
}
|
||||
Some(_) => {}
|
||||
if !has_merge_request {
|
||||
missing.push("merge_request".to_string());
|
||||
}
|
||||
if !has_current_subject_ref {
|
||||
missing.push("current_subject_ref".to_string());
|
||||
@@ -2349,6 +2364,7 @@ fn ticket_query_item(
|
||||
unresolved_blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
||||
evidence: detail.evidence.clone(),
|
||||
merge_requests: detail.merge_requests.clone(),
|
||||
merge_request: detail.merge_request.clone(),
|
||||
current_coder: detail.current_coder.clone(),
|
||||
}
|
||||
@@ -2789,6 +2805,7 @@ mod tests {
|
||||
event_id: "request-1".to_string(),
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
requested_by: reviewer.clone(),
|
||||
reviewer: reviewer.clone(),
|
||||
created_at: requested_at,
|
||||
@@ -2798,6 +2815,7 @@ mod tests {
|
||||
sequence: 2,
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
decision,
|
||||
body: "review body".to_string(),
|
||||
findings: Vec::new(),
|
||||
@@ -2879,33 +2897,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_target_projection_requires_exactly_one_write_target() {
|
||||
fn ticket_target_projection_accepts_multiple_write_targets() {
|
||||
let target = |repository_key: &str, access| ticket::TicketTarget {
|
||||
repository_key: repository_key.to_string(),
|
||||
ref_selector: Some("develop".to_string()),
|
||||
access,
|
||||
};
|
||||
|
||||
assert_eq!(ticket_write_target_repository_key(&[]), None);
|
||||
assert_eq!(
|
||||
ticket_write_target_repository_key(&[target(
|
||||
assert!(ticket_write_target_repository_keys(&[]).is_empty());
|
||||
assert!(
|
||||
ticket_write_target_repository_keys(&[target(
|
||||
"docs",
|
||||
ticket::TicketTargetAccess::ReadOnly,
|
||||
)]),
|
||||
None,
|
||||
)])
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let one_write = [
|
||||
target("main", ticket::TicketTargetAccess::ReadWrite),
|
||||
target("docs", ticket::TicketTargetAccess::ReadOnly),
|
||||
];
|
||||
assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),);
|
||||
assert_eq!(
|
||||
ticket_write_target_repository_keys(&one_write),
|
||||
vec!["main"]
|
||||
);
|
||||
|
||||
let two_writes = [
|
||||
target("main", ticket::TicketTargetAccess::ReadWrite),
|
||||
target("docs", ticket::TicketTargetAccess::ReadWrite),
|
||||
];
|
||||
assert_eq!(ticket_write_target_repository_key(&two_writes), None);
|
||||
assert_eq!(
|
||||
ticket_write_target_repository_keys(&two_writes),
|
||||
vec!["main", "docs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2915,15 +2939,12 @@ mod tests {
|
||||
"docs".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request));
|
||||
let evidence =
|
||||
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&read_only_request));
|
||||
|
||||
assert!(!evidence.has_merge_request);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
assert!(
|
||||
evidence
|
||||
.missing
|
||||
.contains(&"merge_request_repository".to_string())
|
||||
);
|
||||
assert!(evidence.missing.contains(&"merge_request".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2933,19 +2954,19 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved));
|
||||
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved));
|
||||
assert!(evidence.complete_for_integration);
|
||||
assert!(evidence.approved_current_subject);
|
||||
assert!(evidence.review_after_rescope);
|
||||
assert!(evidence.has_commit);
|
||||
|
||||
let with_audit_events = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&["main"],
|
||||
&[
|
||||
ticket_event("comment", "2026-01-01T00:05:00Z", None),
|
||||
ticket_event("implementation_report", "2026-01-01T00:06:00Z", None),
|
||||
],
|
||||
Some(&approved),
|
||||
std::slice::from_ref(&approved),
|
||||
);
|
||||
assert!(with_audit_events.complete_for_integration);
|
||||
|
||||
@@ -2954,7 +2975,7 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked));
|
||||
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&revoked));
|
||||
assert!(!evidence.approved_current_subject);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
|
||||
@@ -2963,7 +2984,7 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes));
|
||||
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&changes));
|
||||
assert!(evidence.unresolved_request_changes);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
}
|
||||
@@ -2975,7 +2996,7 @@ mod tests {
|
||||
"main".to_string(),
|
||||
None,
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved));
|
||||
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&unresolved));
|
||||
assert!(!evidence.has_current_subject_ref);
|
||||
assert!(!evidence.has_commit);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
@@ -2987,10 +3008,10 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed));
|
||||
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&closed));
|
||||
assert!(!evidence.has_merge_request);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
assert!(evidence.missing.contains(&"open_merge_request".to_string()));
|
||||
assert!(evidence.missing.contains(&"merge_request".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3001,38 +3022,38 @@ mod tests {
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let fresh = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&["main"],
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:02:00Z",
|
||||
Some("body"),
|
||||
)],
|
||||
Some(&approved),
|
||||
std::slice::from_ref(&approved),
|
||||
);
|
||||
assert!(fresh.review_after_rescope);
|
||||
assert!(fresh.complete_for_integration);
|
||||
|
||||
let stale = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&["main"],
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("title"),
|
||||
)],
|
||||
Some(&approved),
|
||||
std::slice::from_ref(&approved),
|
||||
);
|
||||
assert!(!stale.review_after_rescope);
|
||||
assert!(!stale.complete_for_integration);
|
||||
assert!(stale.missing.contains(&"review_after_rescope".to_string()));
|
||||
|
||||
let metadata_only = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&["main"],
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("formatter"),
|
||||
)],
|
||||
Some(&approved),
|
||||
std::slice::from_ref(&approved),
|
||||
);
|
||||
assert!(metadata_only.complete_for_integration);
|
||||
}
|
||||
@@ -3044,7 +3065,8 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary));
|
||||
let approved =
|
||||
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved_summary));
|
||||
assert!(ticket_evidence_matches(&approved, "merge_request"));
|
||||
assert!(ticket_evidence_matches(&approved, "commit"));
|
||||
assert!(ticket_evidence_matches(&approved, "approved_review"));
|
||||
@@ -3061,7 +3083,8 @@ mod tests {
|
||||
"main".to_string(),
|
||||
Some("commit-2".to_string()),
|
||||
);
|
||||
let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary));
|
||||
let pending =
|
||||
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&pending_summary));
|
||||
assert!(ticket_attention_matches(
|
||||
"inprogress",
|
||||
&pending,
|
||||
@@ -3076,7 +3099,11 @@ mod tests {
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("targets"),
|
||||
)];
|
||||
let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary));
|
||||
let stale = ticket_evidence_summary(
|
||||
&["main"],
|
||||
&stale_events,
|
||||
std::slice::from_ref(&approved_summary),
|
||||
);
|
||||
assert!(ticket_attention_matches(
|
||||
"inprogress",
|
||||
&stale,
|
||||
|
||||
@@ -3673,9 +3673,9 @@ fn validated_ticket_implementation_targets(
|
||||
.iter()
|
||||
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||
.count();
|
||||
if read_write_count != 1 {
|
||||
if read_write_count == 0 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Ticket `{ticket_id}` requires exactly one read_write target, found {read_write_count}"
|
||||
"Ticket `{ticket_id}` requires at least one read_write target"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
@@ -3732,14 +3732,16 @@ fn require_ticket_read_write_target(
|
||||
) -> ApiResult<ValidatedTicketImplementationTarget> {
|
||||
let write_target = validated_ticket_implementation_targets(api, ticket_id)?
|
||||
.into_iter()
|
||||
.find(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||
.expect("validated Ticket target collection has exactly one read_write target");
|
||||
if write_target.repository_id != repository_id || write_target.ref_selector != selector {
|
||||
return Err(Error::InvalidInput(
|
||||
"selectors must match the authoritative Ticket read_write repository target".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
.find(|target| {
|
||||
target.access == TicketTargetAccess::ReadWrite
|
||||
&& target.repository_id == repository_id
|
||||
&& target.ref_selector == selector
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::InvalidInput(
|
||||
"repository and selector must match a declared Ticket read_write target".into(),
|
||||
))
|
||||
})?;
|
||||
Ok(write_target)
|
||||
}
|
||||
|
||||
@@ -4398,6 +4400,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::merge_request_complete(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_complete(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_close_record(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -7769,6 +7774,25 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn ticket_complete(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
id: String,
|
||||
request: server_api::CompleteTicketRequest,
|
||||
) -> std::result::Result<server_api::TicketCompletionEvent, server_api::RepositoryApiError>
|
||||
{
|
||||
let Json(response) = scoped_complete_ticket(
|
||||
State(self.workspace_api()?.clone()),
|
||||
contract_request_headers(&context)?,
|
||||
AxumPath((workspace_id, id)),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn ticket_close_record(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -12053,6 +12077,24 @@ fn resolve_workspace_worker_ticket_assignment(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_request_ticket_id(mr: &merge_request::MergeRequest) -> ApiResult<String> {
|
||||
match mr.ticket_ids.as_slice() {
|
||||
[ticket_id] => Ok(ticket_id.clone()),
|
||||
[] => Err(
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Corrupt(
|
||||
"Merge Request has no linked Ticket".into(),
|
||||
))
|
||||
.into(),
|
||||
),
|
||||
_ => Err(
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||
"Merge Request must be linked to exactly one workflow Ticket".into(),
|
||||
))
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn public_merge_request_state(
|
||||
state: merge_request::MergeRequestState,
|
||||
) -> server_api::MergeRequestState {
|
||||
@@ -12124,6 +12166,7 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE
|
||||
sequence: event.sequence,
|
||||
request_event_id: event.request_event_id,
|
||||
subject_ref: event.subject_ref,
|
||||
ticket_item_revision: event.ticket_item_revision,
|
||||
decision: public_review_decision(event.decision),
|
||||
body: event.body,
|
||||
findings: event
|
||||
@@ -12166,6 +12209,19 @@ fn public_merge_event(event: merge_request::MergeEvent) -> server_api::MergeEven
|
||||
}
|
||||
}
|
||||
|
||||
fn public_ticket_completion_event(
|
||||
event: merge_request::TicketCompletionEvent,
|
||||
) -> server_api::TicketCompletionEvent {
|
||||
server_api::TicketCompletionEvent {
|
||||
operation_id: event.operation_id,
|
||||
ticket_id: event.ticket_id,
|
||||
item_revision: event.item_revision,
|
||||
merge_request_ids: event.merge_request_ids,
|
||||
completed_by: public_merge_request_worker_identity(event.completed_by),
|
||||
created_at: event.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy {
|
||||
match strategy {
|
||||
merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward,
|
||||
@@ -12213,6 +12269,7 @@ fn public_merge_request_thread_event(
|
||||
event_id: event.event_id,
|
||||
sequence: event.sequence,
|
||||
subject_ref: event.subject_ref,
|
||||
ticket_item_revision: event.ticket_item_revision,
|
||||
requested_by: public_merge_request_worker_identity(event.requested_by),
|
||||
reviewer: public_merge_request_worker_identity(event.reviewer),
|
||||
created_at: event.created_at.to_rfc3339(),
|
||||
@@ -12527,12 +12584,12 @@ async fn scoped_show_merge_request(
|
||||
|
||||
async fn scoped_merge_request_readiness(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<server_api::MergeRequestReadinessResponse>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
||||
@@ -12555,6 +12612,7 @@ async fn scoped_merge_request_readiness(
|
||||
(None, _) => (None, None),
|
||||
};
|
||||
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
current_subject_ref,
|
||||
auth: merge_request::MergeRequestAuth {
|
||||
@@ -12657,14 +12715,13 @@ async fn scoped_open_merge_request(
|
||||
|
||||
async fn scoped_merge_request_thread(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<server_api::MergeRequestThreadQuery>,
|
||||
) -> ApiResult<Json<Vec<server_api::MergeRequestThreadEvent>>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
let events = merge_request_store(&api, &workspace_id)?.thread_page(
|
||||
let events = merge_request_store(&api, &workspace_id)?.thread_page_by_id(
|
||||
&workspace_id,
|
||||
&ticket_id,
|
||||
&merge_request_id,
|
||||
query.after,
|
||||
query.limit.unwrap_or(100),
|
||||
)?;
|
||||
@@ -12679,11 +12736,10 @@ async fn scoped_merge_request_thread(
|
||||
async fn scoped_repair_merge_request_selector(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::RepairMergeRequestSelectorRequest>,
|
||||
) -> ApiResult<Json<server_api::PublicMergeRequest>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
reject_non_browser_reopen_auth(&headers)?;
|
||||
let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?;
|
||||
@@ -12691,7 +12747,8 @@ async fn scoped_repair_merge_request_selector(
|
||||
return Err(Error::BrowserReopenConfirmationRequired.into());
|
||||
}
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
@@ -12708,6 +12765,7 @@ async fn scoped_repair_merge_request_selector(
|
||||
.revision_ref;
|
||||
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
|
||||
workspace_id: workspace_id.clone(),
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
selector_from: input.selector_from,
|
||||
resolved_subject_ref,
|
||||
@@ -12746,13 +12804,15 @@ async fn scoped_register_reviewer_child_session(
|
||||
async fn scoped_register_merge_request_review_capability(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::RegisterMergeRequestReviewCapabilityRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
@@ -12767,8 +12827,6 @@ async fn scoped_register_merge_request_review_capability(
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let selector = mr
|
||||
.selector_from
|
||||
.as_deref()
|
||||
@@ -12788,8 +12846,11 @@ async fn scoped_register_merge_request_review_capability(
|
||||
&source_observation.revision_ref,
|
||||
)?;
|
||||
let subject_ref = source_observation.revision_ref;
|
||||
let ticket_item_revision = api.authority.ticket(&ticket_id)?.item_revision;
|
||||
store.request_review(merge_request::RequestMergeRequestReview {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
ticket_item_revision,
|
||||
subject_ref,
|
||||
child_session_id: input.child_session_id,
|
||||
capability_token: input.capability_token,
|
||||
@@ -12807,15 +12868,16 @@ async fn scoped_register_merge_request_review_capability(
|
||||
|
||||
async fn scoped_submit_merge_request_review(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::SubmitMergeRequestReviewRequest>,
|
||||
) -> ApiResult<Json<server_api::ReviewEvent>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let review_authorization =
|
||||
store.authorize_review_submission(&ticket_id, &input.capability_token)?;
|
||||
if review_authorization.workspace_id != workspace_id {
|
||||
store.authorize_review_submission(&merge_request_id, &input.capability_token)?;
|
||||
if review_authorization.workspace_id != workspace_id
|
||||
|| review_authorization.merge_request_id != merge_request_id
|
||||
{
|
||||
return Err(
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Unauthorized(
|
||||
"review grant invalid".into(),
|
||||
@@ -12823,7 +12885,8 @@ async fn scoped_submit_merge_request_review(
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let ticket_id = review_authorization.ticket_id;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let selector = mr
|
||||
.selector_from
|
||||
.as_deref()
|
||||
@@ -12849,6 +12912,7 @@ async fn scoped_submit_merge_request_review(
|
||||
.map(internal_review_finding)
|
||||
.collect();
|
||||
let event = store.submit_review(merge_request::SubmitMergeRequestReview {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
current_subject_ref,
|
||||
capability_token: input.capability_token,
|
||||
@@ -12863,16 +12927,18 @@ async fn scoped_submit_merge_request_review(
|
||||
async fn scoped_revoke_merge_request_review(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::RevokeMergeRequestReviewRequest>,
|
||||
) -> ApiResult<Json<server_api::ReviewRevokedEvent>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
if !input.explicit_confirmation {
|
||||
return Err(Error::BrowserReopenConfirmationRequired.into());
|
||||
}
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
@@ -12887,9 +12953,8 @@ async fn scoped_revoke_merge_request_review(
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let event = store.revoke_review(merge_request::RevokeMergeRequestReview {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
review_event_id: input.review_event_id,
|
||||
reason: input.reason,
|
||||
@@ -12908,18 +12973,19 @@ async fn scoped_revoke_merge_request_review(
|
||||
async fn scoped_complete_merge_request(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::CompleteMergeRequestRequest>,
|
||||
) -> ApiResult<Json<server_api::MergeEvent>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
require_online_workspace_orchestrator_source(&api, &source)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
|
||||
let replay = merge_request::CompleteMergeRequest {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
operation_id: input.operation_id,
|
||||
approval_event_id: input.approval_event_id,
|
||||
@@ -12974,6 +13040,7 @@ async fn scoped_complete_merge_request(
|
||||
&input.target_ref_after,
|
||||
)?;
|
||||
let completion = merge_request::CompleteMergeRequest {
|
||||
merge_request_id,
|
||||
ticket_id,
|
||||
operation_id: input.operation_id,
|
||||
approval_event_id: input.approval_event_id,
|
||||
@@ -12999,6 +13066,42 @@ async fn scoped_complete_merge_request(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn scoped_complete_ticket(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
Json(input): Json<server_api::CompleteTicketRequest>,
|
||||
) -> ApiResult<Json<server_api::TicketCompletionEvent>> {
|
||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
require_online_workspace_orchestrator_source(&api, &source)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
})?;
|
||||
let event = merge_request_store(&api, &workspace_id)?.complete_ticket(
|
||||
merge_request::CompleteTicket {
|
||||
ticket_id,
|
||||
operation_id: input.operation_id,
|
||||
item_revision: input.item_revision,
|
||||
merge_request_ids: input.merge_request_ids,
|
||||
auth: merge_request::MergeRequestAuth {
|
||||
workspace_id,
|
||||
repository_id: String::new(),
|
||||
runtime_id: source.runtime_id,
|
||||
worker_id: source.worker_id,
|
||||
assignment_id: assignment.assignment_id,
|
||||
},
|
||||
now: Utc::now(),
|
||||
},
|
||||
)?;
|
||||
Ok(Json(public_ticket_completion_event(event)))
|
||||
}
|
||||
|
||||
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
|
||||
if headers.contains_key("authorization") {
|
||||
return Err(Error::BrowserReopenConfirmationRequired);
|
||||
@@ -25809,8 +25912,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
|
||||
let availability =
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
||||
let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
||||
let worker_ref = RuntimeWorkerRef::new("runtime-a", "worker-a");
|
||||
let projection = WorkerRegistryProjectionRecord {
|
||||
registry: WorkerRegistryRecord {
|
||||
@@ -25860,8 +25962,7 @@ mod tests {
|
||||
|
||||
let mut observed_stopped = projection;
|
||||
let observation = observed_stopped.observation.as_mut().unwrap();
|
||||
observation.availability =
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
||||
observation.availability = protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
||||
observation.worker.availability =
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
||||
observation.worker.state = protocol::subscription::SubscriptionWorkerState::Stopped;
|
||||
@@ -26791,8 +26892,7 @@ mod tests {
|
||||
identity: TEST_WORKSPACE_ID.to_string(),
|
||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||
},
|
||||
availability:
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
state: "idle".to_string(),
|
||||
worker_state: None,
|
||||
last_seen_at: None,
|
||||
@@ -38590,8 +38690,7 @@ mod tests {
|
||||
identity: "workspace-test".to_string(),
|
||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||
},
|
||||
availability:
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
state: "idle".to_string(),
|
||||
worker_state: None,
|
||||
last_seen_at: None,
|
||||
@@ -38689,8 +38788,7 @@ mod tests {
|
||||
identity: "workspace-test".to_string(),
|
||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||
},
|
||||
availability:
|
||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
state: "idle".to_string(),
|
||||
worker_state: None,
|
||||
last_seen_at: None,
|
||||
|
||||
@@ -6883,9 +6883,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
params![record.workspace_id, record.ticket_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
if target_count == 0 || read_write_count != 1 {
|
||||
if target_count == 0 || read_write_count == 0 {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"manual Coder assignment requires exactly one read_write repository target"
|
||||
"manual Coder assignment requires at least one read_write repository target"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user