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))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub subject_ref: String,
|
pub subject_ref: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_item_revision: String,
|
||||||
pub requested_by: WorkerIdentity,
|
pub requested_by: WorkerIdentity,
|
||||||
pub reviewer: WorkerIdentity,
|
pub reviewer: WorkerIdentity,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
@@ -108,6 +110,8 @@ pub struct ReviewEvent {
|
|||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub request_event_id: String,
|
pub request_event_id: String,
|
||||||
pub subject_ref: String,
|
pub subject_ref: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_item_revision: String,
|
||||||
pub decision: ReviewDecision,
|
pub decision: ReviewDecision,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub findings: Vec<ReviewFinding>,
|
pub findings: Vec<ReviewFinding>,
|
||||||
@@ -259,7 +263,9 @@ pub struct OpenMergeRequest {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RequestMergeRequestReview {
|
pub struct RequestMergeRequestReview {
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
|
pub ticket_item_revision: String,
|
||||||
pub subject_ref: String,
|
pub subject_ref: String,
|
||||||
pub child_session_id: String,
|
pub child_session_id: String,
|
||||||
pub capability_token: String,
|
pub capability_token: String,
|
||||||
@@ -282,11 +288,14 @@ pub struct RegisterReviewerChildSession {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ReviewSubmissionAuthorization {
|
pub struct ReviewSubmissionAuthorization {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
pub merge_request_id: String,
|
||||||
|
pub ticket_id: String,
|
||||||
pub subject_ref: String,
|
pub subject_ref: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SubmitMergeRequestReview {
|
pub struct SubmitMergeRequestReview {
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub current_subject_ref: String,
|
pub current_subject_ref: String,
|
||||||
pub capability_token: String,
|
pub capability_token: String,
|
||||||
@@ -297,6 +306,7 @@ pub struct SubmitMergeRequestReview {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RevokeMergeRequestReview {
|
pub struct RevokeMergeRequestReview {
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub review_event_id: String,
|
pub review_event_id: String,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
@@ -306,6 +316,7 @@ pub struct RevokeMergeRequestReview {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RepairSelectorFrom {
|
pub struct RepairSelectorFrom {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub selector_from: String,
|
pub selector_from: String,
|
||||||
pub resolved_subject_ref: String,
|
pub resolved_subject_ref: String,
|
||||||
@@ -315,6 +326,7 @@ pub struct RepairSelectorFrom {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ReadinessCheck {
|
pub struct ReadinessCheck {
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub current_subject_ref: Option<String>,
|
pub current_subject_ref: Option<String>,
|
||||||
pub auth: MergeRequestAuth,
|
pub auth: MergeRequestAuth,
|
||||||
@@ -330,6 +342,7 @@ pub struct ReadinessReport {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompleteMergeRequest {
|
pub struct CompleteMergeRequest {
|
||||||
|
pub merge_request_id: String,
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
pub approval_event_id: String,
|
pub approval_event_id: String,
|
||||||
@@ -341,6 +354,24 @@ pub struct CompleteMergeRequest {
|
|||||||
pub auth: MergeRequestAuth,
|
pub auth: MergeRequestAuth,
|
||||||
pub now: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CurrentAssignment {
|
pub struct CurrentAssignment {
|
||||||
pub assignment_id: String,
|
pub assignment_id: String,
|
||||||
@@ -421,13 +452,14 @@ impl MergeRequestStore {
|
|||||||
"selectors must differ".into(),
|
"selectors must differ".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let merge_request_id = i.merge_request_id.clone();
|
||||||
self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?;
|
self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?;
|
||||||
let mut c = self.lock()?;
|
let mut c = self.lock()?;
|
||||||
let t = c.transaction()?;
|
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 {
|
if conflict {
|
||||||
return Err(MergeRequestError::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();
|
let now = i.now.to_rfc3339();
|
||||||
@@ -469,7 +501,7 @@ impl MergeRequestStore {
|
|||||||
}
|
}
|
||||||
t.commit()?;
|
t.commit()?;
|
||||||
drop(c);
|
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(
|
pub fn register_reviewer_child_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -498,7 +530,12 @@ impl MergeRequestStore {
|
|||||||
i: RequestMergeRequestReview,
|
i: RequestMergeRequestReview,
|
||||||
) -> Result<RequestedMergeRequestReview, MergeRequestError> {
|
) -> Result<RequestedMergeRequestReview, MergeRequestError> {
|
||||||
nonempty("subject_ref", &i.subject_ref)?;
|
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)?;
|
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||||
if mr.state != MergeRequestState::Open || mr.selector_from.is_none() {
|
if mr.state != MergeRequestState::Open || mr.selector_from.is_none() {
|
||||||
return Err(MergeRequestError::Conflict(
|
return Err(MergeRequestError::Conflict(
|
||||||
@@ -522,6 +559,7 @@ impl MergeRequestStore {
|
|||||||
event_id: Uuid::now_v7().to_string(),
|
event_id: Uuid::now_v7().to_string(),
|
||||||
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
||||||
subject_ref: i.subject_ref,
|
subject_ref: i.subject_ref,
|
||||||
|
ticket_item_revision: i.ticket_item_revision,
|
||||||
requested_by: WorkerIdentity {
|
requested_by: WorkerIdentity {
|
||||||
runtime_id: i.auth.runtime_id,
|
runtime_id: i.auth.runtime_id,
|
||||||
worker_id: i.auth.worker_id,
|
worker_id: i.auth.worker_id,
|
||||||
@@ -548,25 +586,27 @@ impl MergeRequestStore {
|
|||||||
}
|
}
|
||||||
pub fn authorize_review_submission(
|
pub fn authorize_review_submission(
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
merge_request_id: &str,
|
||||||
capability_token: &str,
|
capability_token: &str,
|
||||||
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
||||||
let connection = self.lock()?;
|
let connection = self.lock()?;
|
||||||
connection
|
connection
|
||||||
.query_row(
|
.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
|
FROM merge_request_review_grants g
|
||||||
JOIN merge_request_ticket_relations rel
|
JOIN merge_request_ticket_relations rel
|
||||||
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
||||||
JOIN merge_requests mr
|
JOIN merge_requests mr
|
||||||
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
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'",
|
AND g.status='issued' AND mr.state='open'",
|
||||||
params![capability_token, ticket_id],
|
params![capability_token, merge_request_id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(ReviewSubmissionAuthorization {
|
Ok(ReviewSubmissionAuthorization {
|
||||||
workspace_id: row.get(0)?,
|
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
|
JOIN merge_requests mr
|
||||||
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
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 rel.ticket_id=?2
|
||||||
|
AND g.merge_request_id=?3
|
||||||
AND g.status='issued' AND mr.state='open'",
|
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| {
|
|r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get(0)?,
|
r.get(0)?,
|
||||||
@@ -613,6 +654,16 @@ impl MergeRequestStore {
|
|||||||
"review grant invalid".into(),
|
"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 {
|
if subject != i.current_subject_ref {
|
||||||
let reason = format!(
|
let reason = format!(
|
||||||
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
|
"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)?,
|
sequence: next_seq(&t, &ws, &mr)?,
|
||||||
request_event_id: req,
|
request_event_id: req,
|
||||||
subject_ref: subject,
|
subject_ref: subject,
|
||||||
|
ticket_item_revision,
|
||||||
decision: i.decision,
|
decision: i.decision,
|
||||||
body: i.body,
|
body: i.body,
|
||||||
findings: i.findings,
|
findings: i.findings,
|
||||||
@@ -657,7 +709,7 @@ impl MergeRequestStore {
|
|||||||
i: RevokeMergeRequestReview,
|
i: RevokeMergeRequestReview,
|
||||||
) -> Result<ReviewRevokedEvent, MergeRequestError> {
|
) -> Result<ReviewRevokedEvent, MergeRequestError> {
|
||||||
bounded_body("revocation reason", &i.reason)?;
|
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)?;
|
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||||
let r = mr
|
let r = mr
|
||||||
.thread
|
.thread
|
||||||
@@ -695,7 +747,7 @@ impl MergeRequestStore {
|
|||||||
Ok(e)
|
Ok(e)
|
||||||
}
|
}
|
||||||
pub fn readiness(&self, i: ReadinessCheck) -> Result<ReadinessReport, MergeRequestError> {
|
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
|
let review = i
|
||||||
.current_subject_ref
|
.current_subject_ref
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -742,7 +794,7 @@ impl MergeRequestStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> {
|
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)?;
|
self.repo(&i.auth, &mr.repository_id)?;
|
||||||
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
||||||
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
|
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
|
||||||
@@ -808,7 +860,8 @@ impl MergeRequestStore {
|
|||||||
self.validate_completion(&i)?;
|
self.validate_completion(&i)?;
|
||||||
let mut connection = self.lock()?;
|
let mut connection = self.lock()?;
|
||||||
let transaction = connection.transaction()?;
|
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)?;
|
.ok_or(MergeRequestError::NotFound)?;
|
||||||
|
|
||||||
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
|
||||||
@@ -872,22 +925,6 @@ impl MergeRequestStore {
|
|||||||
"Ticket must be inprogress".into(),
|
"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 issued_grants = {
|
||||||
let mut statement = transaction.prepare(
|
let mut statement = transaction.prepare(
|
||||||
"SELECT request_event_id,subject_ref,capability_token
|
"SELECT request_event_id,subject_ref,capability_token
|
||||||
@@ -965,13 +1002,195 @@ impl MergeRequestStore {
|
|||||||
Ok(event)
|
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(
|
pub fn repair_selector_from(
|
||||||
&self,
|
&self,
|
||||||
i: RepairSelectorFrom,
|
i: RepairSelectorFrom,
|
||||||
) -> Result<MergeRequest, MergeRequestError> {
|
) -> Result<MergeRequest, MergeRequestError> {
|
||||||
nonempty("selector_from", &i.selector_from)?;
|
nonempty("selector_from", &i.selector_from)?;
|
||||||
nonempty("resolved_subject_ref", &i.resolved_subject_ref)?;
|
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() {
|
if mr.selector_from.is_some() {
|
||||||
return Err(MergeRequestError::Conflict(
|
return Err(MergeRequestError::Conflict(
|
||||||
"selector_from is immutable after it is set".into(),
|
"selector_from is immutable after it is set".into(),
|
||||||
@@ -1011,7 +1230,7 @@ impl MergeRequestStore {
|
|||||||
)?;
|
)?;
|
||||||
t.commit()?;
|
t.commit()?;
|
||||||
drop(c);
|
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> {
|
pub fn get(&self, ws: &str, ticket: &str) -> Result<MergeRequest, MergeRequestError> {
|
||||||
let c = self.lock()?;
|
let c = self.lock()?;
|
||||||
@@ -1021,6 +1240,21 @@ impl MergeRequestStore {
|
|||||||
None => Err(MergeRequestError::NotFound),
|
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(
|
pub fn get_by_id(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -1029,6 +1263,23 @@ impl MergeRequestStore {
|
|||||||
let c = self.lock()?;
|
let c = self.lock()?;
|
||||||
load_mr(&c, workspace_id, merge_request_id)?.ok_or(MergeRequestError::NotFound)
|
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(
|
pub fn list(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -1125,6 +1376,24 @@ impl MergeRequestStore {
|
|||||||
limit.clamp(1, 200),
|
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> {
|
fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
|
||||||
self.repo(a, r)?;
|
self.repo(a, r)?;
|
||||||
let x = self
|
let x = self
|
||||||
@@ -1248,29 +1517,39 @@ fn insert_event<T: Serialize>(
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn load_mr_for_ticket(
|
fn current_ticket_revision(
|
||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
) -> Result<Option<MergeRequest>, MergeRequestError> {
|
) -> Result<Option<String>, MergeRequestError> {
|
||||||
let merge_request_id: Option<String> = connection
|
let event_revision = connection
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT rel.merge_request_id
|
"SELECT attribute.value
|
||||||
FROM merge_request_ticket_relations rel
|
FROM typed_ticket_events event
|
||||||
JOIN merge_requests mr
|
JOIN typed_ticket_event_attributes attribute
|
||||||
ON mr.workspace_id=rel.workspace_id
|
ON attribute.workspace_id=event.workspace_id
|
||||||
AND mr.merge_request_id=rel.merge_request_id
|
AND attribute.ticket_id=event.ticket_id
|
||||||
WHERE rel.workspace_id=?1 AND rel.ticket_id=?2
|
AND attribute.event_index=event.event_index
|
||||||
ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC
|
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",
|
LIMIT 1",
|
||||||
params![workspace_id, ticket_id],
|
params![workspace_id, ticket_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
match merge_request_id {
|
if event_revision.is_some() {
|
||||||
Some(id) => load_mr(connection, workspace_id, &id),
|
return Ok(event_revision);
|
||||||
None => Ok(None),
|
|
||||||
}
|
}
|
||||||
|
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> {
|
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
|
||||||
@@ -1341,9 +1620,10 @@ fn ticket_event(
|
|||||||
) -> Result<(), MergeRequestError> {
|
) -> Result<(), MergeRequestError> {
|
||||||
let ticket = &mr.ticket_ids[0];
|
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))?;
|
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 [
|
for (k, v) in [
|
||||||
("implementation_assignment_id", a),
|
("implementation_assignment_id", a),
|
||||||
|
("merge_request_id", &mr.merge_request_id),
|
||||||
("approval_event_id", &e.approval_event_id),
|
("approval_event_id", &e.approval_event_id),
|
||||||
("approved_source_ref", &e.approved_source_ref),
|
("approved_source_ref", &e.approved_source_ref),
|
||||||
("operation_id", &e.operation_id),
|
("operation_id", &e.operation_id),
|
||||||
@@ -1356,6 +1636,57 @@ fn ticket_event(
|
|||||||
Ok(())
|
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> {
|
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
||||||
match schema_state(c)? {
|
match schema_state(c)? {
|
||||||
SchemaState::Fresh => fresh(c),
|
SchemaState::Fresh => fresh(c),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ impl AssignmentSource for Assignments {
|
|||||||
struct Repositories;
|
struct Repositories;
|
||||||
impl RepositorySource for Repositories {
|
impl RepositorySource for Repositories {
|
||||||
fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result<bool, String> {
|
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> {
|
fn at(s: u32) -> chrono::DateTime<Utc> {
|
||||||
@@ -29,11 +29,17 @@ fn auth() -> MergeRequestAuth {
|
|||||||
assignment_id: "A".into(),
|
assignment_id: "A".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn auth_repo(repository_id: &str) -> MergeRequestAuth {
|
||||||
|
MergeRequestAuth {
|
||||||
|
repository_id: repository_id.into(),
|
||||||
|
..auth()
|
||||||
|
}
|
||||||
|
}
|
||||||
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
let p = d.path().join("db");
|
let p = d.path().join("db");
|
||||||
let c = Connection::open(&p).unwrap();
|
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);
|
drop(c);
|
||||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||||
assignment_id: "A".into(),
|
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();
|
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
|
||||||
(d, s)
|
(d, s)
|
||||||
}
|
}
|
||||||
fn open(s: &MergeRequestStore) {
|
fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) {
|
||||||
s.open_merge_request(OpenMergeRequest {
|
s.open_merge_request(OpenMergeRequest {
|
||||||
merge_request_id: "MR".into(),
|
merge_request_id: merge_request_id.into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
repository_id: "R".into(),
|
repository_id: repository_id.into(),
|
||||||
selector_from: "work/t".into(),
|
selector_from: if merge_request_id == "MR" {
|
||||||
|
"work/t".into()
|
||||||
|
} else {
|
||||||
|
format!("work/{merge_request_id}")
|
||||||
|
},
|
||||||
selector_to: "develop".into(),
|
selector_to: "develop".into(),
|
||||||
summary: "summary".into(),
|
summary: "summary".into(),
|
||||||
auth: auth(),
|
auth: auth_repo(repository_id),
|
||||||
now: at(1),
|
now: at(1),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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 {
|
s.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||||
workspace_id: "W".into(),
|
workspace_id: "W".into(),
|
||||||
parent_runtime_id: "runtime".into(),
|
parent_runtime_id: "runtime".into(),
|
||||||
@@ -68,19 +87,31 @@ fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequested
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
s.request_review(RequestMergeRequestReview {
|
s.request_review(RequestMergeRequestReview {
|
||||||
|
merge_request_id: merge_request_id.into(),
|
||||||
|
ticket_item_revision: "t".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
subject_ref: subject.into(),
|
subject_ref: subject.into(),
|
||||||
child_session_id: format!("child-{token}"),
|
child_session_id: format!("child-{token}"),
|
||||||
capability_token: token.into(),
|
capability_token: token.into(),
|
||||||
auth: auth(),
|
auth: auth_repo(repository_id),
|
||||||
now: at(3),
|
now: at(3),
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.request_event
|
.request_event
|
||||||
}
|
}
|
||||||
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent {
|
||||||
request(s, subject, token);
|
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 {
|
s.submit_review(SubmitMergeRequestReview {
|
||||||
|
merge_request_id: merge_request_id.into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: subject.into(),
|
current_subject_ref: subject.into(),
|
||||||
capability_token: token.into(),
|
capability_token: token.into(),
|
||||||
@@ -91,6 +122,9 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
|||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||||
|
approve_for(s, "MR", "R", subject, token)
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
||||||
let (_d, store) = fixture();
|
let (_d, store) = fixture();
|
||||||
@@ -98,13 +132,15 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects()
|
|||||||
request(&store, "published-source", "valid-token");
|
request(&store, "published-source", "valid-token");
|
||||||
|
|
||||||
let invalid = store
|
let invalid = store
|
||||||
.authorize_review_submission("T", "invalid-token")
|
.authorize_review_submission("MR", "invalid-token")
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
||||||
let authorized = store
|
let authorized = store
|
||||||
.authorize_review_submission("T", "valid-token")
|
.authorize_review_submission("MR", "valid-token")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(authorized.workspace_id, "W");
|
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");
|
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 review = approve(&s, "opaque-source-ref", "token");
|
||||||
let ready = s
|
let ready = s
|
||||||
.readiness(ReadinessCheck {
|
.readiness(ReadinessCheck {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: Some("opaque-source-ref".into()),
|
current_subject_ref: Some("opaque-source-ref".into()),
|
||||||
auth: auth(),
|
auth: auth(),
|
||||||
@@ -123,6 +160,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
|||||||
assert!(ready.ready);
|
assert!(ready.ready);
|
||||||
let merged = s
|
let merged = s
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "op".into(),
|
operation_id: "op".into(),
|
||||||
approval_event_id: review.event_id,
|
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),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!current_assignment);
|
assert!(current_assignment);
|
||||||
let replayed = s
|
let replayed = s
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "op".into(),
|
operation_id: "op".into(),
|
||||||
approval_event_id: merged.approval_event_id.clone(),
|
approval_event_id: merged.approval_event_id.clone(),
|
||||||
@@ -166,6 +205,41 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(replayed, merged);
|
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();
|
let json = serde_json::to_string(&mr).unwrap();
|
||||||
for banned in [
|
for banned in [
|
||||||
"revision_id",
|
"revision_id",
|
||||||
@@ -187,6 +261,7 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
|
|||||||
request(&s, "source-b", "two");
|
request(&s, "source-b", "two");
|
||||||
assert!(
|
assert!(
|
||||||
s.submit_review(SubmitMergeRequestReview {
|
s.submit_review(SubmitMergeRequestReview {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: "source-c".into(),
|
current_subject_ref: "source-c".into(),
|
||||||
capability_token: "two".into(),
|
capability_token: "two".into(),
|
||||||
@@ -221,6 +296,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o
|
|||||||
|
|
||||||
let stale = s
|
let stale = s
|
||||||
.readiness(ReadinessCheck {
|
.readiness(ReadinessCheck {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: Some("source-2".into()),
|
current_subject_ref: Some("source-2".into()),
|
||||||
auth: auth(),
|
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 second = approve(&s, "source-2", "two");
|
||||||
let ready = s
|
let ready = s
|
||||||
.readiness(ReadinessCheck {
|
.readiness(ReadinessCheck {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: Some("source-2".into()),
|
current_subject_ref: Some("source-2".into()),
|
||||||
auth: auth(),
|
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.
|
// integration evidence for the current target pair.
|
||||||
let merged = s
|
let merged = s
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
operation_id: "target-moved".into(),
|
operation_id: "target-moved".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: "source-2".into(),
|
current_subject_ref: "source-2".into(),
|
||||||
@@ -283,6 +361,7 @@ fn review_revocation_invalidates_readiness() {
|
|||||||
open(&s);
|
open(&s);
|
||||||
let review = approve(&s, "source", "one");
|
let review = approve(&s, "source", "one");
|
||||||
s.revoke_review(RevokeMergeRequestReview {
|
s.revoke_review(RevokeMergeRequestReview {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
review_event_id: review.event_id,
|
review_event_id: review.event_id,
|
||||||
reason: "bad evidence".into(),
|
reason: "bad evidence".into(),
|
||||||
@@ -292,6 +371,7 @@ fn review_revocation_invalidates_readiness() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let r = s
|
let r = s
|
||||||
.readiness(ReadinessCheck {
|
.readiness(ReadinessCheck {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: Some("source".into()),
|
current_subject_ref: Some("source".into()),
|
||||||
auth: auth(),
|
auth: auth(),
|
||||||
@@ -366,6 +446,7 @@ fn completion_rejects_superseded_approval_for_same_subject() {
|
|||||||
request(&store, "subject", "changes");
|
request(&store, "subject", "changes");
|
||||||
store
|
store
|
||||||
.submit_review(SubmitMergeRequestReview {
|
.submit_review(SubmitMergeRequestReview {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: "subject".into(),
|
current_subject_ref: "subject".into(),
|
||||||
capability_token: "changes".into(),
|
capability_token: "changes".into(),
|
||||||
@@ -376,6 +457,7 @@ fn completion_rejects_superseded_approval_for_same_subject() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let result = store.complete(CompleteMergeRequest {
|
let result = store.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "op".into(),
|
operation_id: "op".into(),
|
||||||
approval_event_id: old_approval.event_id,
|
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");
|
request(&store, "other-subject", "pending");
|
||||||
store
|
store
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "op".into(),
|
operation_id: "op".into(),
|
||||||
approval_event_id: approval.event_id,
|
approval_event_id: approval.event_id,
|
||||||
@@ -411,6 +494,7 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let late = store.submit_review(SubmitMergeRequestReview {
|
let late = store.submit_review(SubmitMergeRequestReview {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
current_subject_ref: "other-subject".into(),
|
current_subject_ref: "other-subject".into(),
|
||||||
capability_token: "pending".into(),
|
capability_token: "pending".into(),
|
||||||
@@ -436,6 +520,7 @@ fn selector_repair_requires_and_accepts_an_approved_resolved_subject() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let repaired = store
|
let repaired = store
|
||||||
.repair_selector_from(RepairSelectorFrom {
|
.repair_selector_from(RepairSelectorFrom {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
workspace_id: "W".into(),
|
workspace_id: "W".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
selector_from: "restored-work".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'", [])
|
.execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let result = store.repair_selector_from(RepairSelectorFrom {
|
let result = store.repair_selector_from(RepairSelectorFrom {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
workspace_id: "W".into(),
|
workspace_id: "W".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
selector_from: "wrong-work".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'", [])
|
.execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let result = store.complete(CompleteMergeRequest {
|
let result = store.complete(CompleteMergeRequest {
|
||||||
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "op".into(),
|
operation_id: "op".into(),
|
||||||
approval_event_id: approval.event_id,
|
approval_event_id: approval.event_id,
|
||||||
@@ -574,3 +661,158 @@ fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(state, "inprogress");
|
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,
|
CancelTicketImplementationRequest,
|
||||||
ClearTicketRoleAssignmentQuery,
|
ClearTicketRoleAssignmentQuery,
|
||||||
CompleteMergeRequestRequest,
|
CompleteMergeRequestRequest,
|
||||||
|
CompleteTicketRequest,
|
||||||
CreateTicketOrchestrationPlanRequest,
|
CreateTicketOrchestrationPlanRequest,
|
||||||
CreateTicketRecordRequest,
|
CreateTicketRecordRequest,
|
||||||
CreateTicketRelationRequest,
|
CreateTicketRelationRequest,
|
||||||
@@ -279,6 +280,7 @@ impl_openapi_schema!(
|
|||||||
SubmitMergeRequestReviewRequest,
|
SubmitMergeRequestReviewRequest,
|
||||||
TextResponse,
|
TextResponse,
|
||||||
TicketCloseRecordRequest,
|
TicketCloseRecordRequest,
|
||||||
|
TicketCompletionEvent,
|
||||||
TicketDependencyCheckResponse,
|
TicketDependencyCheckResponse,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketDoctorResponse,
|
TicketDoctorResponse,
|
||||||
@@ -2616,20 +2618,20 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: OpenMergeRequestRequest,
|
#[body] request: OpenMergeRequestRequest,
|
||||||
) -> Result<PublicMergeRequest, RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_readiness(
|
||||||
&self,
|
&self,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
) -> Result<MergeRequestReadinessResponse, RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_thread(
|
||||||
&self,
|
&self,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[query] query: MergeRequestThreadQuery,
|
#[query] query: MergeRequestThreadQuery,
|
||||||
) -> Result<MergeRequestThreadResponse, RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_selector_repair(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2644,7 +2646,7 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[body] request: RegisterReviewerChildSessionRequest,
|
#[body] request: RegisterReviewerChildSessionRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_review_capability_register(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2652,14 +2654,14 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: RegisterMergeRequestReviewCapabilityRequest,
|
#[body] request: RegisterMergeRequestReviewCapabilityRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_review_submit(
|
||||||
&self,
|
&self,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: SubmitMergeRequestReviewRequest,
|
#[body] request: SubmitMergeRequestReviewRequest,
|
||||||
) -> Result<ReviewEvent, RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_review_revoke(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2667,7 +2669,7 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: RevokeMergeRequestReviewRequest,
|
#[body] request: RevokeMergeRequestReviewRequest,
|
||||||
) -> Result<ReviewRevokedEvent, RepositoryApiError>;
|
) -> 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(
|
async fn merge_request_complete(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2675,6 +2677,14 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: CompleteMergeRequestRequest,
|
#[body] request: CompleteMergeRequestRequest,
|
||||||
) -> Result<MergeEvent, RepositoryApiError>;
|
) -> 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)]
|
#[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(
|
async fn ticket_close_record(
|
||||||
&self,
|
&self,
|
||||||
@@ -5881,6 +5891,7 @@ pub struct TicketQueryItem {
|
|||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub unresolved_review_count: usize,
|
pub unresolved_review_count: usize,
|
||||||
pub evidence: TicketEvidenceSummary,
|
pub evidence: TicketEvidenceSummary,
|
||||||
|
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||||
pub current_coder: Option<TicketAssignmentSummary>,
|
pub current_coder: Option<TicketAssignmentSummary>,
|
||||||
}
|
}
|
||||||
@@ -5939,6 +5950,7 @@ pub struct TicketDetail {
|
|||||||
pub current_coder: Option<TicketAssignmentSummary>,
|
pub current_coder: Option<TicketAssignmentSummary>,
|
||||||
pub assignment_diagnostics: Vec<String>,
|
pub assignment_diagnostics: Vec<String>,
|
||||||
pub action_eligibility: TicketActionEligibility,
|
pub action_eligibility: TicketActionEligibility,
|
||||||
|
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||||
pub evidence: TicketEvidenceSummary,
|
pub evidence: TicketEvidenceSummary,
|
||||||
pub resolution: Option<String>,
|
pub resolution: Option<String>,
|
||||||
@@ -6297,12 +6309,14 @@ macro_rules! merge_request_event {
|
|||||||
|
|
||||||
merge_request_event!(ReviewRequestedEvent {
|
merge_request_event!(ReviewRequestedEvent {
|
||||||
subject_ref: String,
|
subject_ref: String,
|
||||||
|
ticket_item_revision: String,
|
||||||
requested_by: MergeRequestWorkerIdentity,
|
requested_by: MergeRequestWorkerIdentity,
|
||||||
reviewer: MergeRequestWorkerIdentity,
|
reviewer: MergeRequestWorkerIdentity,
|
||||||
});
|
});
|
||||||
merge_request_event!(ReviewEvent {
|
merge_request_event!(ReviewEvent {
|
||||||
request_event_id: String,
|
request_event_id: String,
|
||||||
subject_ref: String,
|
subject_ref: String,
|
||||||
|
ticket_item_revision: String,
|
||||||
decision: ReviewDecision,
|
decision: ReviewDecision,
|
||||||
body: String,
|
body: String,
|
||||||
findings: Vec<ReviewFinding>,
|
findings: Vec<ReviewFinding>,
|
||||||
@@ -6513,6 +6527,25 @@ pub struct CompleteMergeRequestRequest {
|
|||||||
pub resolution: ConflictResolution,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeManagementApiError {
|
pub struct RuntimeManagementApiError {
|
||||||
@@ -9630,7 +9663,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|operation| operation.operation_id.starts_with("ticket_"))
|
.filter(|operation| operation.operation_id.starts_with("ticket_"))
|
||||||
.count(),
|
.count(),
|
||||||
37
|
38
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
operations
|
operations
|
||||||
@@ -9660,7 +9693,7 @@ mod tests {
|
|||||||
(
|
(
|
||||||
"merge_request_complete",
|
"merge_request_complete",
|
||||||
HttpMethod::Post,
|
HttpMethod::Post,
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/complete",
|
"/api/w/{workspace_id}/merge-requests/{id}/complete",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"objective_ticket_unlink",
|
"objective_ticket_unlink",
|
||||||
@@ -11292,6 +11325,7 @@ mod openapi_artifact_tests {
|
|||||||
"merge_request_review_revoke",
|
"merge_request_review_revoke",
|
||||||
"merge_request_review_submit",
|
"merge_request_review_submit",
|
||||||
"merge_request_reviewer_child_register",
|
"merge_request_reviewer_child_register",
|
||||||
|
"ticket_complete",
|
||||||
"runtime_resource_fetch",
|
"runtime_resource_fetch",
|
||||||
"worker_control_cancel",
|
"worker_control_cancel",
|
||||||
"worker_control_input",
|
"worker_control_input",
|
||||||
|
|||||||
+44
-41
@@ -85,7 +85,7 @@ pub enum TicketError {
|
|||||||
},
|
},
|
||||||
#[error("ticket target repository `{0}` appears more than once")]
|
#[error("ticket target repository `{0}` appears more than once")]
|
||||||
DuplicateTargetRepository(String),
|
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),
|
InvalidReadWriteTargetCount(usize),
|
||||||
#[error("ticket target authority is unavailable")]
|
#[error("ticket target authority is unavailable")]
|
||||||
TargetAuthorityUnavailable,
|
TargetAuthorityUnavailable,
|
||||||
@@ -649,7 +649,7 @@ fn resolve_ready_targets(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||||
.count();
|
.count();
|
||||||
if read_write_count != 1 {
|
if read_write_count == 0 {
|
||||||
return Err(TicketError::InvalidReadWriteTargetCount(read_write_count));
|
return Err(TicketError::InvalidReadWriteTargetCount(read_write_count));
|
||||||
}
|
}
|
||||||
let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?;
|
let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?;
|
||||||
@@ -6009,45 +6009,48 @@ mod tests {
|
|||||||
Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main"
|
Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main"
|
||||||
));
|
));
|
||||||
|
|
||||||
for (name, targets, expected_count) in [
|
let mut no_write = NewTicket::new("No write target");
|
||||||
(
|
no_write.targets = vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)];
|
||||||
"No write target",
|
let ticket = backend.create(no_write).unwrap();
|
||||||
vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)],
|
let error = backend
|
||||||
0,
|
.mark_ready(
|
||||||
),
|
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||||
(
|
TicketMarkReady {
|
||||||
"Multiple write targets",
|
operation_key: "ready-no-write".into(),
|
||||||
vec![write_target("main"), write_target("docs")],
|
reason: None,
|
||||||
2,
|
author: None,
|
||||||
),
|
intake_summary: None,
|
||||||
] {
|
},
|
||||||
let mut input = NewTicket::new(name);
|
)
|
||||||
input.targets = targets;
|
.unwrap_err();
|
||||||
let ticket = backend.create(input).unwrap();
|
assert!(matches!(error, TicketError::InvalidReadWriteTargetCount(0)));
|
||||||
let error = backend
|
let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||||
.mark_ready(
|
assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
|
||||||
TicketMarkReady {
|
let mut multiple_writes = NewTicket::new("Multiple write targets");
|
||||||
operation_key: format!("ready-{expected_count}"),
|
multiple_writes.targets = vec![write_target("main"), write_target("docs")];
|
||||||
reason: None,
|
let ticket = backend.create(multiple_writes).unwrap();
|
||||||
author: None,
|
let ready = backend
|
||||||
intake_summary: None,
|
.mark_ready(
|
||||||
},
|
TicketIdOrSlug::Id(ticket.id),
|
||||||
)
|
TicketMarkReady {
|
||||||
.unwrap_err();
|
operation_key: "ready-multiple-writes".into(),
|
||||||
assert!(matches!(
|
reason: None,
|
||||||
error,
|
author: None,
|
||||||
TicketError::InvalidReadWriteTargetCount(count) if count == expected_count
|
intake_summary: None,
|
||||||
));
|
},
|
||||||
let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
)
|
||||||
assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning);
|
.unwrap();
|
||||||
assert!(
|
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
|
||||||
unchanged
|
assert_eq!(
|
||||||
.events
|
ready
|
||||||
.iter()
|
.meta
|
||||||
.all(|event| !event.attributes.contains_key("operation_key"))
|
.targets
|
||||||
);
|
.iter()
|
||||||
}
|
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
|
|||||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
"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. \
|
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.";
|
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. \
|
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, \
|
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")
|
.expect("static Merge Request workflow instruction declaration is valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_KINDS: [Kind; 5] = [
|
const ALL_KINDS: [Kind; 6] = [
|
||||||
Kind::Show,
|
Kind::Show,
|
||||||
Kind::Open,
|
Kind::Open,
|
||||||
Kind::Review,
|
Kind::Review,
|
||||||
Kind::Readiness,
|
Kind::Readiness,
|
||||||
Kind::Complete,
|
Kind::Complete,
|
||||||
|
Kind::CompleteTicket,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -42,6 +43,7 @@ enum Kind {
|
|||||||
Readiness,
|
Readiness,
|
||||||
Open,
|
Open,
|
||||||
Complete,
|
Complete,
|
||||||
|
CompleteTicket,
|
||||||
Review,
|
Review,
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -50,8 +52,8 @@ struct MergeRequestTool {
|
|||||||
kind: Kind,
|
kind: Kind,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct TicketInput {
|
struct MergeRequestInput {
|
||||||
ticket: String,
|
merge_request_id: String,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct OpenMergeRequestInput {
|
struct OpenMergeRequestInput {
|
||||||
@@ -64,7 +66,7 @@ struct OpenMergeRequestInput {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct CompleteMergeRequestInput {
|
struct CompleteMergeRequestInput {
|
||||||
ticket: String,
|
merge_request_id: String,
|
||||||
operation_id: String,
|
operation_id: String,
|
||||||
approval_event_id: String,
|
approval_event_id: String,
|
||||||
target_ref_before: String,
|
target_ref_before: String,
|
||||||
@@ -73,6 +75,13 @@ struct CompleteMergeRequestInput {
|
|||||||
resolution: MergeResolutionInput,
|
resolution: MergeResolutionInput,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum MergeStrategyInput {
|
enum MergeStrategyInput {
|
||||||
FastForward,
|
FastForward,
|
||||||
@@ -110,16 +119,6 @@ struct ReviewFindingInput {
|
|||||||
line: Option<u32>,
|
line: Option<u32>,
|
||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TicketMergeRequestProjection {
|
|
||||||
merge_request: Option<TicketMergeRequestReference>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TicketMergeRequestReference {
|
|
||||||
merge_request_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Kind {
|
impl Kind {
|
||||||
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
|
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
|
||||||
match self {
|
match self {
|
||||||
@@ -127,7 +126,7 @@ impl Kind {
|
|||||||
Self::Open => config.open,
|
Self::Open => config.open,
|
||||||
Self::Review => config.review,
|
Self::Review => config.review,
|
||||||
Self::Readiness => config.readiness_check,
|
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::Readiness => "CheckMergeRequestReadiness",
|
||||||
Self::Open => "OpenMergeRequest",
|
Self::Open => "OpenMergeRequest",
|
||||||
Self::Complete => "CompleteMergeRequest",
|
Self::Complete => "CompleteMergeRequest",
|
||||||
|
Self::CompleteTicket => "CompleteTicket",
|
||||||
Self::Review => "ReviewMergeRequest",
|
Self::Review => "ReviewMergeRequest",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn schema(self) -> serde_json::Value {
|
fn schema(self) -> serde_json::Value {
|
||||||
match self {
|
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::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||||
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||||
|
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
|
||||||
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,17 +157,20 @@ impl Tool for MergeRequestTool {
|
|||||||
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||||
})?;
|
})?;
|
||||||
if matches!(self.kind, Kind::Show) {
|
if matches!(self.kind, Kind::Show) {
|
||||||
let value: TicketInput = parse(input)?;
|
let value: MergeRequestInput = parse(input)?;
|
||||||
nonempty(&value.ticket)?;
|
nonempty_named("merge_request_id", &value.merge_request_id)?;
|
||||||
return self.show_current_merge_request(ws, &value.ticket);
|
return self.show_merge_request(ws, &value.merge_request_id);
|
||||||
}
|
}
|
||||||
let (method, path, body) = match self.kind {
|
let (method, path, body) = match self.kind {
|
||||||
Kind::Readiness => {
|
Kind::Readiness => {
|
||||||
let v: TicketInput = parse(input)?;
|
let v: MergeRequestInput = parse(input)?;
|
||||||
nonempty(&v.ticket)?;
|
nonempty_named("merge_request_id", &v.merge_request_id)?;
|
||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Get,
|
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,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,15 +188,34 @@ impl Tool for MergeRequestTool {
|
|||||||
}
|
}
|
||||||
Kind::Complete => {
|
Kind::Complete => {
|
||||||
let v: CompleteMergeRequestInput = parse(input)?;
|
let v: CompleteMergeRequestInput = parse(input)?;
|
||||||
nonempty(&v.ticket)?;
|
nonempty_named("merge_request_id", &v.merge_request_id)?;
|
||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Post,
|
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(
|
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"}}),
|
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 => {
|
Kind::Review => {
|
||||||
let v: ReviewMergeRequestInput = parse(input)?;
|
let v: ReviewMergeRequestInput = parse(input)?;
|
||||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||||
@@ -203,8 +226,8 @@ impl Tool for MergeRequestTool {
|
|||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!(
|
format!(
|
||||||
"/api/w/{ws}/tickets/{}/merge-request/reviews",
|
"/api/w/{ws}/merge-requests/{}/reviews",
|
||||||
ctx.ticket_id
|
encode_path_segment(&ctx.merge_request_id)
|
||||||
),
|
),
|
||||||
Some(
|
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<_>>() }),
|
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 {
|
impl MergeRequestTool {
|
||||||
fn show_current_merge_request(
|
fn show_merge_request(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
ticket: &str,
|
merge_request_id: &str,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let ticket_path = encode_path_segment(ticket);
|
let merge_request_id = encode_path_segment(merge_request_id);
|
||||||
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 response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.execute(WorkspaceRequest::get(format!(
|
.execute(WorkspaceRequest::get(format!(
|
||||||
@@ -313,10 +313,10 @@ fn encode_path_segment(value: &str) -> String {
|
|||||||
encoded
|
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) {
|
if value.trim().is_empty() || value.chars().any(char::is_control) {
|
||||||
Err(ToolError::ExecutionFailed(format!(
|
Err(ToolError::InvalidArgument(format!(
|
||||||
"Ticket Show API returned an invalid {name}"
|
"{name} must not be empty"
|
||||||
)))
|
)))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
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.",
|
"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(
|
"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(
|
"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(
|
"ReviewMergeRequest" => Some(
|
||||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
"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",
|
"CheckMergeRequestReadiness",
|
||||||
"OpenMergeRequest",
|
"OpenMergeRequest",
|
||||||
"CompleteMergeRequest",
|
"CompleteMergeRequest",
|
||||||
|
"CompleteTicket",
|
||||||
"ReviewMergeRequest",
|
"ReviewMergeRequest",
|
||||||
] {
|
] {
|
||||||
assert!(description(name).is_some(), "missing operation {name}");
|
assert!(description(name).is_some(), "missing operation {name}");
|
||||||
@@ -497,18 +501,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
|
async fn show_reads_explicit_merge_request_resource() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
||||||
response(json!({"merge_request":{"merge_request_id":"MR/1"}})),
|
json!({"merge_request_id":"MR/1","state":"open"}),
|
||||||
response(json!({"merge_request_id":"MR/1","state":"open"})),
|
)]));
|
||||||
]));
|
|
||||||
let tool = MergeRequestTool {
|
let tool = MergeRequestTool {
|
||||||
kind: Kind::Show,
|
kind: Kind::Show,
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = tool
|
let output = tool
|
||||||
.execute(r#"{"ticket":"T/1"}"#, ToolExecutionContext::default())
|
.execute(
|
||||||
|
r#"{"merge_request_id":"MR/1"}"#,
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("show should succeed");
|
.expect("show should succeed");
|
||||||
|
|
||||||
@@ -517,18 +523,14 @@ mod tests {
|
|||||||
Some(r#"{"merge_request_id":"MR/1","state":"open"}"#)
|
Some(r#"{"merge_request_id":"MR/1","state":"open"}"#)
|
||||||
);
|
);
|
||||||
let requests = client.requests.lock().expect("request lock");
|
let requests = client.requests.lock().expect("request lock");
|
||||||
assert_eq!(requests.len(), 2);
|
assert_eq!(requests.len(), 1);
|
||||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
assert_eq!(requests[0].path, "/api/w/ws/tickets/T%2F1/show");
|
assert_eq!(requests[0].path, "/api/w/ws/merge-requests/MR%2F1");
|
||||||
assert_eq!(requests[1].method, WorkspaceRequestMethod::Get);
|
|
||||||
assert_eq!(requests[1].path, "/api/w/ws/merge-requests/MR%2F1");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_fails_closed_when_ticket_has_no_current_merge_request() {
|
async fn show_requires_explicit_merge_request_identity() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
||||||
json!({"merge_request":null}),
|
|
||||||
)]));
|
|
||||||
let tool = MergeRequestTool {
|
let tool = MergeRequestTool {
|
||||||
kind: Kind::Show,
|
kind: Kind::Show,
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
@@ -537,10 +539,10 @@ mod tests {
|
|||||||
let error = tool
|
let error = tool
|
||||||
.execute(r#"{"ticket":"T1"}"#, ToolExecutionContext::default())
|
.execute(r#"{"ticket":"T1"}"#, ToolExecutionContext::default())
|
||||||
.await
|
.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!(error.to_string().contains("merge_request_id"));
|
||||||
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
assert!(client.requests.lock().expect("request lock").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
|
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
|
||||||
@@ -596,7 +598,8 @@ mod tests {
|
|||||||
[
|
[
|
||||||
"ShowMergeRequest",
|
"ShowMergeRequest",
|
||||||
"CheckMergeRequestReadiness",
|
"CheckMergeRequestReadiness",
|
||||||
"CompleteMergeRequest"
|
"CompleteMergeRequest",
|
||||||
|
"CompleteTicket"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct ModelTicketQueryItem {
|
|||||||
unresolved_blocker_count: usize,
|
unresolved_blocker_count: usize,
|
||||||
unresolved_review_count: usize,
|
unresolved_review_count: usize,
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
merge_request: Option<ModelMergeRequest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail {
|
|||||||
assignments: Vec<ModelAssignment>,
|
assignments: Vec<ModelAssignment>,
|
||||||
current_coder: Option<ModelWorkerSummary>,
|
current_coder: Option<ModelWorkerSummary>,
|
||||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||||
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
merge_request: Option<ModelMergeRequest>,
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
actions: Option<ModelTicketActions>,
|
actions: Option<ModelTicketActions>,
|
||||||
@@ -162,6 +164,8 @@ struct ModelEvidenceEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ModelMergeRequest {
|
struct ModelMergeRequest {
|
||||||
|
merge_request_id: String,
|
||||||
|
repository_key: String,
|
||||||
state: String,
|
state: String,
|
||||||
selector_from: Option<String>,
|
selector_from: Option<String>,
|
||||||
selector_to: 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_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
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
|
merge_request: item
|
||||||
.get("merge_request")
|
.get("merge_request")
|
||||||
.filter(|value| !value.is_null())
|
.filter(|value| !value.is_null())
|
||||||
@@ -295,6 +303,10 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
.iter()
|
.iter()
|
||||||
.map(project_evidence_event)
|
.map(project_evidence_event)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
merge_requests: array_field(root, "merge_requests")?
|
||||||
|
.iter()
|
||||||
|
.map(project_merge_request)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
merge_request: root
|
merge_request: root
|
||||||
.get("merge_request")
|
.get("merge_request")
|
||||||
.filter(|value| !value.is_null())
|
.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> {
|
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||||
let merge = object(value, "Merge Request summary")?;
|
let merge = object(value, "Merge Request summary")?;
|
||||||
Ok(ModelMergeRequest {
|
Ok(ModelMergeRequest {
|
||||||
|
merge_request_id: string_field(merge, "merge_request_id")?,
|
||||||
|
repository_key: string_field(merge, "repository_key")?,
|
||||||
state: string_field(merge, "state")?,
|
state: string_field(merge, "state")?,
|
||||||
selector_from: optional_string(merge, "selector_from")?,
|
selector_from: optional_string(merge, "selector_from")?,
|
||||||
selector_to: string_field(merge, "selector_to")?,
|
selector_to: string_field(merge, "selector_to")?,
|
||||||
review_status: string_field(merge, "review_status")?,
|
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")?,
|
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -721,6 +735,7 @@ mod tests {
|
|||||||
"complete_for_integration": false,
|
"complete_for_integration": false,
|
||||||
"missing": ["merge_request"]
|
"missing": ["merge_request"]
|
||||||
},
|
},
|
||||||
|
"merge_requests": [],
|
||||||
"merge_request": null
|
"merge_request": null
|
||||||
}]
|
}]
|
||||||
})).expect("Ticket query projection");
|
})).expect("Ticket query projection");
|
||||||
@@ -788,6 +803,7 @@ mod tests {
|
|||||||
"implementation_reports": [],
|
"implementation_reports": [],
|
||||||
"assignments": [],
|
"assignments": [],
|
||||||
"current_coder": null,
|
"current_coder": null,
|
||||||
|
"merge_requests": [],
|
||||||
"merge_request": null,
|
"merge_request": null,
|
||||||
"evidence": {
|
"evidence": {
|
||||||
"has_merge_request": false,
|
"has_merge_request": false,
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ mod tests {
|
|||||||
"ReviewMergeRequest",
|
"ReviewMergeRequest",
|
||||||
"CheckMergeRequestReadiness",
|
"CheckMergeRequestReadiness",
|
||||||
"CompleteMergeRequest",
|
"CompleteMergeRequest",
|
||||||
|
"CompleteTicket",
|
||||||
] {
|
] {
|
||||||
assert!(combined.contains(operation), "missing {operation}");
|
assert!(combined.contains(operation), "missing {operation}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ struct SubWorkerSpawnInput {
|
|||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct ReviewerHandoffInput {
|
struct ReviewerHandoffInput {
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
|
merge_request_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -369,9 +370,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
|||||||
let Some(review) = &input.review else {
|
let Some(review) = &input.review else {
|
||||||
return Ok(());
|
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(
|
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") {
|
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| {
|
let reviewer_capability = input.review.as_ref().map(|review| {
|
||||||
(
|
(
|
||||||
review.ticket_id.clone(),
|
review.ticket_id.clone(),
|
||||||
|
review.merge_request_id.clone(),
|
||||||
format!(
|
format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
uuid::Uuid::now_v7().simple(),
|
uuid::Uuid::now_v7().simple(),
|
||||||
@@ -492,7 +494,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let child_workspace_context =
|
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 =
|
let workspace_id =
|
||||||
self.workspace_context
|
self.workspace_context
|
||||||
.workspace_id()
|
.workspace_id()
|
||||||
@@ -513,6 +515,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
parent_client.clone(),
|
parent_client.clone(),
|
||||||
ReviewerContext {
|
ReviewerContext {
|
||||||
ticket_id: ticket_id.clone(),
|
ticket_id: ticket_id.clone(),
|
||||||
|
merge_request_id: merge_request_id.clone(),
|
||||||
},
|
},
|
||||||
capability_token.clone(),
|
capability_token.clone(),
|
||||||
));
|
));
|
||||||
@@ -610,7 +613,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
child_registry
|
child_registry
|
||||||
.attach_parent_protocol(session.protocol_sender(), session.session_id_string());
|
.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(|| {
|
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||||
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
||||||
})?;
|
})?;
|
||||||
@@ -646,9 +649,9 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
let request = WorkspaceRequest::json(
|
let request = WorkspaceRequest::json(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!(
|
format!(
|
||||||
"/api/w/{}/tickets/{}/merge-request/review-capabilities",
|
"/api/w/{}/merge-requests/{}/review-capabilities",
|
||||||
workspace_id.as_str(),
|
workspace_id.as_str(),
|
||||||
ticket_id
|
merge_request_id
|
||||||
),
|
),
|
||||||
body.to_string(),
|
body.to_string(),
|
||||||
);
|
);
|
||||||
@@ -1170,21 +1173,21 @@ mod tests {
|
|||||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
"scope":[{"target":"work","permission":"write"}],
|
"scope":[{"target":"work","permission":"write"}],
|
||||||
"command":true,
|
"command":true,
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
"name":"reviewer","task":"review","profile":"builtin:coder",
|
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||||
"scope":[{"target":"work","permission":"write"}],
|
"scope":[{"target":"work","permission":"write"}],
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||||
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
"scope":[{"target":"work","permission":"read"}],
|
"scope":[{"target":"work","permission":"read"}],
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1","merge_request_id":"MR1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_reviewer_handoff(&read_only).is_err());
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ReviewerContext {
|
pub struct ReviewerContext {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
|
pub merge_request_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -771,8 +772,9 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
|||||||
let ticket_id = &self.context.ticket_id;
|
let ticket_id = &self.context.ticket_id;
|
||||||
let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query");
|
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 ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show");
|
||||||
|
let merge_request_id = &self.context.merge_request_id;
|
||||||
let review_path =
|
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
|
let read_allowed = request.method == WorkspaceRequestMethod::Get
|
||||||
|| (request.method == WorkspaceRequestMethod::Post
|
|| (request.method == WorkspaceRequestMethod::Post
|
||||||
&& (request.path == ticket_query_path || request.path == ticket_show_path));
|
&& (request.path == ticket_query_path || request.path == ticket_show_path));
|
||||||
@@ -952,6 +954,7 @@ mod reviewer_client_tests {
|
|||||||
inner,
|
inner,
|
||||||
ReviewerContext {
|
ReviewerContext {
|
||||||
ticket_id: "T1".into(),
|
ticket_id: "T1".into(),
|
||||||
|
merge_request_id: "MR1".into(),
|
||||||
},
|
},
|
||||||
"secret".into(),
|
"secret".into(),
|
||||||
)
|
)
|
||||||
@@ -1015,7 +1018,7 @@ mod reviewer_client_tests {
|
|||||||
client
|
client
|
||||||
.execute(WorkspaceRequest::json(
|
.execute(WorkspaceRequest::json(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
"/api/w/ws/tickets/T1/merge-request/reviews",
|
"/api/w/ws/merge-requests/MR1/reviews",
|
||||||
r#"{"decision":"approve"}"#.to_string(),
|
r#"{"decision":"approve"}"#.to_string(),
|
||||||
))
|
))
|
||||||
.expect("attested review should be forwarded");
|
.expect("attested review should be forwarded");
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ use std::{path::PathBuf, sync::Arc};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use merge_request::{
|
use merge_request::{
|
||||||
MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent,
|
MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision,
|
||||||
ReviewDecision,
|
|
||||||
};
|
};
|
||||||
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
||||||
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
||||||
@@ -856,8 +855,8 @@ impl SqliteWorkspaceAuthority {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let write_repository_key = ticket_write_target_repository_key(&targets);
|
let write_repository_keys = ticket_write_target_repository_keys(&targets);
|
||||||
let has_target = write_repository_key.is_some();
|
let has_target = !write_repository_keys.is_empty();
|
||||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||||
let mut queue_assignment_blockers = Vec::new();
|
let mut queue_assignment_blockers = Vec::new();
|
||||||
for ticket_id in &dependency_check.queue_tickets {
|
for ticket_id in &dependency_check.queue_tickets {
|
||||||
@@ -929,8 +928,12 @@ impl SqliteWorkspaceAuthority {
|
|||||||
queue_tickets: dependency_check.queue_tickets.clone(),
|
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||||
blockers: action_blockers,
|
blockers: action_blockers,
|
||||||
};
|
};
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_requests = self
|
||||||
Ok(request) => {
|
.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| {
|
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||||
self.merge_revision_source
|
self.merge_revision_source
|
||||||
.resolve_subject_ref(&request.repository_id, selector)
|
.resolve_subject_ref(&request.repository_id, selector)
|
||||||
@@ -940,17 +943,16 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.get_repository(&self.workspace_id, &request.repository_id)?
|
.get_repository(&self.workspace_id, &request.repository_id)?
|
||||||
.map(|repository| repository.repository_key)
|
.map(|repository| repository.repository_key)
|
||||||
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
|
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
|
||||||
Some(merge_request_summary(
|
Ok(merge_request_summary(
|
||||||
request,
|
request,
|
||||||
repository_key,
|
repository_key,
|
||||||
current_subject_ref,
|
current_subject_ref,
|
||||||
))
|
))
|
||||||
}
|
})
|
||||||
Err(MergeRequestError::NotFound) => None,
|
.collect::<Result<Vec<_>>>()?;
|
||||||
Err(error) => return Err(Error::Store(error.to_string())),
|
let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone());
|
||||||
};
|
|
||||||
let evidence =
|
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
|
let item_revision = ticket
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1031,6 +1033,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
current_coder,
|
current_coder,
|
||||||
assignment_diagnostics,
|
assignment_diagnostics,
|
||||||
action_eligibility,
|
action_eligibility,
|
||||||
|
merge_requests,
|
||||||
merge_request,
|
merge_request,
|
||||||
evidence,
|
evidence,
|
||||||
resolution: ticket
|
resolution: ticket
|
||||||
@@ -1850,81 +1853,93 @@ fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
|
|||||||
.map(|value| value.with_timezone(&Utc))
|
.map(|value| value.with_timezone(&Utc))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> {
|
fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<&str> {
|
||||||
let mut write_targets = targets
|
targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite);
|
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite)
|
||||||
let write_target = write_targets.next()?;
|
.map(|target| target.repository_key.as_str())
|
||||||
write_targets
|
.collect()
|
||||||
.next()
|
|
||||||
.is_none()
|
|
||||||
.then_some(write_target.repository_key.as_str())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_evidence_summary(
|
fn ticket_evidence_summary(
|
||||||
ticket_write_repository_key: Option<&str>,
|
ticket_write_repository_keys: &[&str],
|
||||||
events: &[TicketEvent],
|
events: &[TicketEvent],
|
||||||
merge_request: Option<&TicketMergeRequestSummary>,
|
merge_requests: &[TicketMergeRequestSummary],
|
||||||
) -> TicketEvidenceSummary {
|
) -> TicketEvidenceSummary {
|
||||||
let linked_merge_request = merge_request.filter(|request| {
|
let relevant = merge_requests
|
||||||
request.state == "open"
|
.iter()
|
||||||
&& ticket_write_repository_key
|
.filter(|request| {
|
||||||
.is_some_and(|repository_key| repository_key == request.repository_key)
|
request.state != "closed"
|
||||||
});
|
&& ticket_write_repository_keys.contains(&request.repository_key.as_str())
|
||||||
let has_merge_request = linked_merge_request.is_some();
|
})
|
||||||
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
|
.collect::<Vec<_>>();
|
||||||
request
|
let has_merge_request = !relevant.is_empty();
|
||||||
.current_subject_ref
|
let has_current_subject_ref = has_merge_request
|
||||||
.as_deref()
|
&& relevant.iter().all(|request| {
|
||||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
request.state == "merged"
|
||||||
});
|
|| request
|
||||||
let has_review_request = linked_merge_request.is_some_and(|request| {
|
.current_subject_ref
|
||||||
request
|
.as_deref()
|
||||||
.review_subject_ref
|
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||||
.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 has_commit = has_current_subject_ref;
|
||||||
let review_status = linked_merge_request.map(|request| request.review_status.clone());
|
let unresolved_request_changes = relevant
|
||||||
let approved_current_subject = review_status.as_deref() == Some("approved");
|
.iter()
|
||||||
let unresolved_request_changes = review_status.as_deref() == Some("changes_requested");
|
.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
|
let latest_rescope = events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| substantive_item_edit(event))
|
.filter(|event| substantive_item_edit(event))
|
||||||
.last();
|
.last();
|
||||||
let review_after_rescope = approved_current_subject
|
let review_after_rescope = approved_current_subject
|
||||||
&& latest_rescope.is_none_or(|rescope| {
|
&& relevant.iter().all(|request| {
|
||||||
let Some(rescope_at) = event_timestamp(rescope) else {
|
latest_rescope.is_none_or(|rescope| {
|
||||||
return false;
|
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())
|
let Some(requested_at) = request
|
||||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
.review_requested_at
|
||||||
.map(|value| value.with_timezone(&Utc))
|
.as_deref()
|
||||||
else {
|
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||||
return false;
|
.map(|value| value.with_timezone(&Utc))
|
||||||
};
|
else {
|
||||||
let Some(reviewed_at) = linked_merge_request
|
return false;
|
||||||
.and_then(|request| request.review_submitted_at.as_deref())
|
};
|
||||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
let Some(reviewed_at) = request
|
||||||
.map(|value| value.with_timezone(&Utc))
|
.review_submitted_at
|
||||||
else {
|
.as_deref()
|
||||||
return false;
|
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||||
};
|
.map(|value| value.with_timezone(&Utc))
|
||||||
requested_at > rescope_at && reviewed_at > rescope_at
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
requested_at > rescope_at && reviewed_at > rescope_at
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
match merge_request {
|
if !has_merge_request {
|
||||||
None => missing.push("merge_request".to_string()),
|
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_current_subject_ref {
|
if !has_current_subject_ref {
|
||||||
missing.push("current_subject_ref".to_string());
|
missing.push("current_subject_ref".to_string());
|
||||||
@@ -2349,6 +2364,7 @@ fn ticket_query_item(
|
|||||||
unresolved_blocker_count: detail.relations.blockers.len(),
|
unresolved_blocker_count: detail.relations.blockers.len(),
|
||||||
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
||||||
evidence: detail.evidence.clone(),
|
evidence: detail.evidence.clone(),
|
||||||
|
merge_requests: detail.merge_requests.clone(),
|
||||||
merge_request: detail.merge_request.clone(),
|
merge_request: detail.merge_request.clone(),
|
||||||
current_coder: detail.current_coder.clone(),
|
current_coder: detail.current_coder.clone(),
|
||||||
}
|
}
|
||||||
@@ -2789,6 +2805,7 @@ mod tests {
|
|||||||
event_id: "request-1".to_string(),
|
event_id: "request-1".to_string(),
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
subject_ref: "commit-1".to_string(),
|
subject_ref: "commit-1".to_string(),
|
||||||
|
ticket_item_revision: "revision-1".to_string(),
|
||||||
requested_by: reviewer.clone(),
|
requested_by: reviewer.clone(),
|
||||||
reviewer: reviewer.clone(),
|
reviewer: reviewer.clone(),
|
||||||
created_at: requested_at,
|
created_at: requested_at,
|
||||||
@@ -2798,6 +2815,7 @@ mod tests {
|
|||||||
sequence: 2,
|
sequence: 2,
|
||||||
request_event_id: "request-1".to_string(),
|
request_event_id: "request-1".to_string(),
|
||||||
subject_ref: "commit-1".to_string(),
|
subject_ref: "commit-1".to_string(),
|
||||||
|
ticket_item_revision: "revision-1".to_string(),
|
||||||
decision,
|
decision,
|
||||||
body: "review body".to_string(),
|
body: "review body".to_string(),
|
||||||
findings: Vec::new(),
|
findings: Vec::new(),
|
||||||
@@ -2879,33 +2897,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 {
|
let target = |repository_key: &str, access| ticket::TicketTarget {
|
||||||
repository_key: repository_key.to_string(),
|
repository_key: repository_key.to_string(),
|
||||||
ref_selector: Some("develop".to_string()),
|
ref_selector: Some("develop".to_string()),
|
||||||
access,
|
access,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(ticket_write_target_repository_key(&[]), None);
|
assert!(ticket_write_target_repository_keys(&[]).is_empty());
|
||||||
assert_eq!(
|
assert!(
|
||||||
ticket_write_target_repository_key(&[target(
|
ticket_write_target_repository_keys(&[target(
|
||||||
"docs",
|
"docs",
|
||||||
ticket::TicketTargetAccess::ReadOnly,
|
ticket::TicketTargetAccess::ReadOnly,
|
||||||
)]),
|
)])
|
||||||
None,
|
.is_empty()
|
||||||
);
|
);
|
||||||
|
|
||||||
let one_write = [
|
let one_write = [
|
||||||
target("main", ticket::TicketTargetAccess::ReadWrite),
|
target("main", ticket::TicketTargetAccess::ReadWrite),
|
||||||
target("docs", ticket::TicketTargetAccess::ReadOnly),
|
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 = [
|
let two_writes = [
|
||||||
target("main", ticket::TicketTargetAccess::ReadWrite),
|
target("main", ticket::TicketTargetAccess::ReadWrite),
|
||||||
target("docs", 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]
|
#[test]
|
||||||
@@ -2915,15 +2939,12 @@ mod tests {
|
|||||||
"docs".to_string(),
|
"docs".to_string(),
|
||||||
Some("commit-1".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.has_merge_request);
|
||||||
assert!(!evidence.complete_for_integration);
|
assert!(!evidence.complete_for_integration);
|
||||||
assert!(
|
assert!(evidence.missing.contains(&"merge_request".to_string()));
|
||||||
evidence
|
|
||||||
.missing
|
|
||||||
.contains(&"merge_request_repository".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2933,19 +2954,19 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-1".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.complete_for_integration);
|
||||||
assert!(evidence.approved_current_subject);
|
assert!(evidence.approved_current_subject);
|
||||||
assert!(evidence.review_after_rescope);
|
assert!(evidence.review_after_rescope);
|
||||||
assert!(evidence.has_commit);
|
assert!(evidence.has_commit);
|
||||||
|
|
||||||
let with_audit_events = ticket_evidence_summary(
|
let with_audit_events = ticket_evidence_summary(
|
||||||
Some("main"),
|
&["main"],
|
||||||
&[
|
&[
|
||||||
ticket_event("comment", "2026-01-01T00:05:00Z", None),
|
ticket_event("comment", "2026-01-01T00:05:00Z", None),
|
||||||
ticket_event("implementation_report", "2026-01-01T00:06: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);
|
assert!(with_audit_events.complete_for_integration);
|
||||||
|
|
||||||
@@ -2954,7 +2975,7 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-1".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.approved_current_subject);
|
||||||
assert!(!evidence.complete_for_integration);
|
assert!(!evidence.complete_for_integration);
|
||||||
|
|
||||||
@@ -2963,7 +2984,7 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-1".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.unresolved_request_changes);
|
||||||
assert!(!evidence.complete_for_integration);
|
assert!(!evidence.complete_for_integration);
|
||||||
}
|
}
|
||||||
@@ -2975,7 +2996,7 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
None,
|
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_current_subject_ref);
|
||||||
assert!(!evidence.has_commit);
|
assert!(!evidence.has_commit);
|
||||||
assert!(!evidence.complete_for_integration);
|
assert!(!evidence.complete_for_integration);
|
||||||
@@ -2987,10 +3008,10 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-1".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.has_merge_request);
|
||||||
assert!(!evidence.complete_for_integration);
|
assert!(!evidence.complete_for_integration);
|
||||||
assert!(evidence.missing.contains(&"open_merge_request".to_string()));
|
assert!(evidence.missing.contains(&"merge_request".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3001,38 +3022,38 @@ mod tests {
|
|||||||
Some("commit-1".to_string()),
|
Some("commit-1".to_string()),
|
||||||
);
|
);
|
||||||
let fresh = ticket_evidence_summary(
|
let fresh = ticket_evidence_summary(
|
||||||
Some("main"),
|
&["main"],
|
||||||
&[ticket_event(
|
&[ticket_event(
|
||||||
"item_edit",
|
"item_edit",
|
||||||
"2026-01-01T00:02:00Z",
|
"2026-01-01T00:02:00Z",
|
||||||
Some("body"),
|
Some("body"),
|
||||||
)],
|
)],
|
||||||
Some(&approved),
|
std::slice::from_ref(&approved),
|
||||||
);
|
);
|
||||||
assert!(fresh.review_after_rescope);
|
assert!(fresh.review_after_rescope);
|
||||||
assert!(fresh.complete_for_integration);
|
assert!(fresh.complete_for_integration);
|
||||||
|
|
||||||
let stale = ticket_evidence_summary(
|
let stale = ticket_evidence_summary(
|
||||||
Some("main"),
|
&["main"],
|
||||||
&[ticket_event(
|
&[ticket_event(
|
||||||
"item_edit",
|
"item_edit",
|
||||||
"2026-01-01T00:05:00Z",
|
"2026-01-01T00:05:00Z",
|
||||||
Some("title"),
|
Some("title"),
|
||||||
)],
|
)],
|
||||||
Some(&approved),
|
std::slice::from_ref(&approved),
|
||||||
);
|
);
|
||||||
assert!(!stale.review_after_rescope);
|
assert!(!stale.review_after_rescope);
|
||||||
assert!(!stale.complete_for_integration);
|
assert!(!stale.complete_for_integration);
|
||||||
assert!(stale.missing.contains(&"review_after_rescope".to_string()));
|
assert!(stale.missing.contains(&"review_after_rescope".to_string()));
|
||||||
|
|
||||||
let metadata_only = ticket_evidence_summary(
|
let metadata_only = ticket_evidence_summary(
|
||||||
Some("main"),
|
&["main"],
|
||||||
&[ticket_event(
|
&[ticket_event(
|
||||||
"item_edit",
|
"item_edit",
|
||||||
"2026-01-01T00:05:00Z",
|
"2026-01-01T00:05:00Z",
|
||||||
Some("formatter"),
|
Some("formatter"),
|
||||||
)],
|
)],
|
||||||
Some(&approved),
|
std::slice::from_ref(&approved),
|
||||||
);
|
);
|
||||||
assert!(metadata_only.complete_for_integration);
|
assert!(metadata_only.complete_for_integration);
|
||||||
}
|
}
|
||||||
@@ -3044,7 +3065,8 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-1".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, "merge_request"));
|
||||||
assert!(ticket_evidence_matches(&approved, "commit"));
|
assert!(ticket_evidence_matches(&approved, "commit"));
|
||||||
assert!(ticket_evidence_matches(&approved, "approved_review"));
|
assert!(ticket_evidence_matches(&approved, "approved_review"));
|
||||||
@@ -3061,7 +3083,8 @@ mod tests {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
Some("commit-2".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(
|
assert!(ticket_attention_matches(
|
||||||
"inprogress",
|
"inprogress",
|
||||||
&pending,
|
&pending,
|
||||||
@@ -3076,7 +3099,11 @@ mod tests {
|
|||||||
"2026-01-01T00:05:00Z",
|
"2026-01-01T00:05:00Z",
|
||||||
Some("targets"),
|
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(
|
assert!(ticket_attention_matches(
|
||||||
"inprogress",
|
"inprogress",
|
||||||
&stale,
|
&stale,
|
||||||
|
|||||||
@@ -3673,9 +3673,9 @@ fn validated_ticket_implementation_targets(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
|
||||||
.count();
|
.count();
|
||||||
if read_write_count != 1 {
|
if read_write_count == 0 {
|
||||||
return Err(Error::InvalidInput(format!(
|
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());
|
.into());
|
||||||
}
|
}
|
||||||
@@ -3732,14 +3732,16 @@ fn require_ticket_read_write_target(
|
|||||||
) -> ApiResult<ValidatedTicketImplementationTarget> {
|
) -> ApiResult<ValidatedTicketImplementationTarget> {
|
||||||
let write_target = validated_ticket_implementation_targets(api, ticket_id)?
|
let write_target = validated_ticket_implementation_targets(api, ticket_id)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|target| target.access == TicketTargetAccess::ReadWrite)
|
.find(|target| {
|
||||||
.expect("validated Ticket target collection has exactly one read_write target");
|
target.access == TicketTargetAccess::ReadWrite
|
||||||
if write_target.repository_id != repository_id || write_target.ref_selector != selector {
|
&& target.repository_id == repository_id
|
||||||
return Err(Error::InvalidInput(
|
&& target.ref_selector == selector
|
||||||
"selectors must match the authoritative Ticket read_write repository target".into(),
|
})
|
||||||
)
|
.ok_or_else(|| {
|
||||||
.into());
|
ApiError::from(Error::InvalidInput(
|
||||||
}
|
"repository and selector must match a declared Ticket read_write target".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(write_target)
|
Ok(write_target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4398,6 +4400,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::merge_request_complete(
|
.merge(server_api::server_api_axum::merge_request_complete(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
.merge(server_api::server_api_axum::ticket_complete(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
.merge(server_api::server_api_axum::ticket_close_record(
|
.merge(server_api::server_api_axum::ticket_close_record(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -7769,6 +7774,25 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
Ok(response)
|
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(
|
async fn ticket_close_record(
|
||||||
&self,
|
&self,
|
||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
@@ -12053,6 +12077,24 @@ fn resolve_workspace_worker_ticket_assignment(
|
|||||||
Ok(())
|
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(
|
fn public_merge_request_state(
|
||||||
state: merge_request::MergeRequestState,
|
state: merge_request::MergeRequestState,
|
||||||
) -> server_api::MergeRequestState {
|
) -> server_api::MergeRequestState {
|
||||||
@@ -12124,6 +12166,7 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE
|
|||||||
sequence: event.sequence,
|
sequence: event.sequence,
|
||||||
request_event_id: event.request_event_id,
|
request_event_id: event.request_event_id,
|
||||||
subject_ref: event.subject_ref,
|
subject_ref: event.subject_ref,
|
||||||
|
ticket_item_revision: event.ticket_item_revision,
|
||||||
decision: public_review_decision(event.decision),
|
decision: public_review_decision(event.decision),
|
||||||
body: event.body,
|
body: event.body,
|
||||||
findings: event
|
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 {
|
fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy {
|
||||||
match strategy {
|
match strategy {
|
||||||
merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward,
|
merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward,
|
||||||
@@ -12213,6 +12269,7 @@ fn public_merge_request_thread_event(
|
|||||||
event_id: event.event_id,
|
event_id: event.event_id,
|
||||||
sequence: event.sequence,
|
sequence: event.sequence,
|
||||||
subject_ref: event.subject_ref,
|
subject_ref: event.subject_ref,
|
||||||
|
ticket_item_revision: event.ticket_item_revision,
|
||||||
requested_by: public_merge_request_worker_identity(event.requested_by),
|
requested_by: public_merge_request_worker_identity(event.requested_by),
|
||||||
reviewer: public_merge_request_worker_identity(event.reviewer),
|
reviewer: public_merge_request_worker_identity(event.reviewer),
|
||||||
created_at: event.created_at.to_rfc3339(),
|
created_at: event.created_at.to_rfc3339(),
|
||||||
@@ -12527,12 +12584,12 @@ async fn scoped_show_merge_request(
|
|||||||
|
|
||||||
async fn scoped_merge_request_readiness(
|
async fn scoped_merge_request_readiness(
|
||||||
State(api): State<WorkspaceApi>,
|
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>> {
|
) -> ApiResult<Json<server_api::MergeRequestReadinessResponse>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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 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
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
||||||
@@ -12555,6 +12612,7 @@ async fn scoped_merge_request_readiness(
|
|||||||
(None, _) => (None, None),
|
(None, _) => (None, None),
|
||||||
};
|
};
|
||||||
let mut report = store.readiness(merge_request::ReadinessCheck {
|
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
current_subject_ref,
|
current_subject_ref,
|
||||||
auth: merge_request::MergeRequestAuth {
|
auth: merge_request::MergeRequestAuth {
|
||||||
@@ -12657,14 +12715,13 @@ async fn scoped_open_merge_request(
|
|||||||
|
|
||||||
async fn scoped_merge_request_thread(
|
async fn scoped_merge_request_thread(
|
||||||
State(api): State<WorkspaceApi>,
|
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>,
|
Query(query): Query<server_api::MergeRequestThreadQuery>,
|
||||||
) -> ApiResult<Json<Vec<server_api::MergeRequestThreadEvent>>> {
|
) -> ApiResult<Json<Vec<server_api::MergeRequestThreadEvent>>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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_by_id(
|
||||||
let events = merge_request_store(&api, &workspace_id)?.thread_page(
|
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&ticket_id,
|
&merge_request_id,
|
||||||
query.after,
|
query.after,
|
||||||
query.limit.unwrap_or(100),
|
query.limit.unwrap_or(100),
|
||||||
)?;
|
)?;
|
||||||
@@ -12679,11 +12736,10 @@ async fn scoped_merge_request_thread(
|
|||||||
async fn scoped_repair_merge_request_selector(
|
async fn scoped_repair_merge_request_selector(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
headers: HeaderMap,
|
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>,
|
Json(input): Json<server_api::RepairMergeRequestSelectorRequest>,
|
||||||
) -> ApiResult<Json<server_api::PublicMergeRequest>> {
|
) -> ApiResult<Json<server_api::PublicMergeRequest>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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)?;
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
reject_non_browser_reopen_auth(&headers)?;
|
reject_non_browser_reopen_auth(&headers)?;
|
||||||
let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?;
|
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());
|
return Err(Error::BrowserReopenConfirmationRequired.into());
|
||||||
}
|
}
|
||||||
let store = merge_request_store(&api, &workspace_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
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
@@ -12708,6 +12765,7 @@ async fn scoped_repair_merge_request_selector(
|
|||||||
.revision_ref;
|
.revision_ref;
|
||||||
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
|
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
selector_from: input.selector_from,
|
selector_from: input.selector_from,
|
||||||
resolved_subject_ref,
|
resolved_subject_ref,
|
||||||
@@ -12746,13 +12804,15 @@ async fn scoped_register_reviewer_child_session(
|
|||||||
async fn scoped_register_merge_request_review_capability(
|
async fn scoped_register_merge_request_review_capability(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
headers: HeaderMap,
|
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>,
|
Json(input): Json<server_api::RegisterMergeRequestReviewCapabilityRequest>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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)?;
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
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
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
@@ -12767,8 +12827,6 @@ async fn scoped_register_merge_request_review_capability(
|
|||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
|
||||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
|
||||||
let selector = mr
|
let selector = mr
|
||||||
.selector_from
|
.selector_from
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -12788,8 +12846,11 @@ async fn scoped_register_merge_request_review_capability(
|
|||||||
&source_observation.revision_ref,
|
&source_observation.revision_ref,
|
||||||
)?;
|
)?;
|
||||||
let subject_ref = 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 {
|
store.request_review(merge_request::RequestMergeRequestReview {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
|
ticket_item_revision,
|
||||||
subject_ref,
|
subject_ref,
|
||||||
child_session_id: input.child_session_id,
|
child_session_id: input.child_session_id,
|
||||||
capability_token: input.capability_token,
|
capability_token: input.capability_token,
|
||||||
@@ -12807,15 +12868,16 @@ async fn scoped_register_merge_request_review_capability(
|
|||||||
|
|
||||||
async fn scoped_submit_merge_request_review(
|
async fn scoped_submit_merge_request_review(
|
||||||
State(api): State<WorkspaceApi>,
|
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>,
|
Json(input): Json<server_api::SubmitMergeRequestReviewRequest>,
|
||||||
) -> ApiResult<Json<server_api::ReviewEvent>> {
|
) -> ApiResult<Json<server_api::ReviewEvent>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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 store = merge_request_store(&api, &workspace_id)?;
|
||||||
let review_authorization =
|
let review_authorization =
|
||||||
store.authorize_review_submission(&ticket_id, &input.capability_token)?;
|
store.authorize_review_submission(&merge_request_id, &input.capability_token)?;
|
||||||
if review_authorization.workspace_id != workspace_id {
|
if review_authorization.workspace_id != workspace_id
|
||||||
|
|| review_authorization.merge_request_id != merge_request_id
|
||||||
|
{
|
||||||
return Err(
|
return Err(
|
||||||
Error::MergeRequest(merge_request::MergeRequestError::Unauthorized(
|
Error::MergeRequest(merge_request::MergeRequestError::Unauthorized(
|
||||||
"review grant invalid".into(),
|
"review grant invalid".into(),
|
||||||
@@ -12823,7 +12885,8 @@ async fn scoped_submit_merge_request_review(
|
|||||||
.into(),
|
.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
|
let selector = mr
|
||||||
.selector_from
|
.selector_from
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -12849,6 +12912,7 @@ async fn scoped_submit_merge_request_review(
|
|||||||
.map(internal_review_finding)
|
.map(internal_review_finding)
|
||||||
.collect();
|
.collect();
|
||||||
let event = store.submit_review(merge_request::SubmitMergeRequestReview {
|
let event = store.submit_review(merge_request::SubmitMergeRequestReview {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
current_subject_ref,
|
current_subject_ref,
|
||||||
capability_token: input.capability_token,
|
capability_token: input.capability_token,
|
||||||
@@ -12863,16 +12927,18 @@ async fn scoped_submit_merge_request_review(
|
|||||||
async fn scoped_revoke_merge_request_review(
|
async fn scoped_revoke_merge_request_review(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
headers: HeaderMap,
|
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>,
|
Json(input): Json<server_api::RevokeMergeRequestReviewRequest>,
|
||||||
) -> ApiResult<Json<server_api::ReviewRevokedEvent>> {
|
) -> ApiResult<Json<server_api::ReviewRevokedEvent>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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)?;
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
if !input.explicit_confirmation {
|
if !input.explicit_confirmation {
|
||||||
return Err(Error::BrowserReopenConfirmationRequired.into());
|
return Err(Error::BrowserReopenConfirmationRequired.into());
|
||||||
}
|
}
|
||||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
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
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
@@ -12887,9 +12953,8 @@ async fn scoped_revoke_merge_request_review(
|
|||||||
)
|
)
|
||||||
.into());
|
.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 {
|
let event = store.revoke_review(merge_request::RevokeMergeRequestReview {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
review_event_id: input.review_event_id,
|
review_event_id: input.review_event_id,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
@@ -12908,18 +12973,19 @@ async fn scoped_revoke_merge_request_review(
|
|||||||
async fn scoped_complete_merge_request(
|
async fn scoped_complete_merge_request(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
headers: HeaderMap,
|
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>,
|
Json(input): Json<server_api::CompleteMergeRequestRequest>,
|
||||||
) -> ApiResult<Json<server_api::MergeEvent>> {
|
) -> ApiResult<Json<server_api::MergeEvent>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
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)?;
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
require_online_workspace_orchestrator_source(&api, &source)?;
|
require_online_workspace_orchestrator_source(&api, &source)?;
|
||||||
let store = merge_request_store(&api, &workspace_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)?;
|
||||||
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
|
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
|
||||||
let replay = merge_request::CompleteMergeRequest {
|
let replay = merge_request::CompleteMergeRequest {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
operation_id: input.operation_id,
|
operation_id: input.operation_id,
|
||||||
approval_event_id: input.approval_event_id,
|
approval_event_id: input.approval_event_id,
|
||||||
@@ -12974,6 +13040,7 @@ async fn scoped_complete_merge_request(
|
|||||||
&input.target_ref_after,
|
&input.target_ref_after,
|
||||||
)?;
|
)?;
|
||||||
let completion = merge_request::CompleteMergeRequest {
|
let completion = merge_request::CompleteMergeRequest {
|
||||||
|
merge_request_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
operation_id: input.operation_id,
|
operation_id: input.operation_id,
|
||||||
approval_event_id: input.approval_event_id,
|
approval_event_id: input.approval_event_id,
|
||||||
@@ -12999,6 +13066,42 @@ async fn scoped_complete_merge_request(
|
|||||||
.map_err(Into::into)
|
.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<()> {
|
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
|
||||||
if headers.contains_key("authorization") {
|
if headers.contains_key("authorization") {
|
||||||
return Err(Error::BrowserReopenConfirmationRequired);
|
return Err(Error::BrowserReopenConfirmationRequired);
|
||||||
@@ -25809,8 +25912,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
|
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
|
||||||
let availability =
|
let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
|
||||||
let worker_ref = RuntimeWorkerRef::new("runtime-a", "worker-a");
|
let worker_ref = RuntimeWorkerRef::new("runtime-a", "worker-a");
|
||||||
let projection = WorkerRegistryProjectionRecord {
|
let projection = WorkerRegistryProjectionRecord {
|
||||||
registry: WorkerRegistryRecord {
|
registry: WorkerRegistryRecord {
|
||||||
@@ -25860,8 +25962,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut observed_stopped = projection;
|
let mut observed_stopped = projection;
|
||||||
let observation = observed_stopped.observation.as_mut().unwrap();
|
let observation = observed_stopped.observation.as_mut().unwrap();
|
||||||
observation.availability =
|
observation.availability = protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
|
||||||
observation.worker.availability =
|
observation.worker.availability =
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
protocol::subscription::SubscriptionWorkerAvailability::Observed;
|
||||||
observation.worker.state = protocol::subscription::SubscriptionWorkerState::Stopped;
|
observation.worker.state = protocol::subscription::SubscriptionWorkerState::Stopped;
|
||||||
@@ -26791,8 +26892,7 @@ mod tests {
|
|||||||
identity: TEST_WORKSPACE_ID.to_string(),
|
identity: TEST_WORKSPACE_ID.to_string(),
|
||||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||||
},
|
},
|
||||||
availability:
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
|
||||||
state: "idle".to_string(),
|
state: "idle".to_string(),
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
@@ -38590,8 +38690,7 @@ mod tests {
|
|||||||
identity: "workspace-test".to_string(),
|
identity: "workspace-test".to_string(),
|
||||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||||
},
|
},
|
||||||
availability:
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
|
||||||
state: "idle".to_string(),
|
state: "idle".to_string(),
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
@@ -38689,8 +38788,7 @@ mod tests {
|
|||||||
identity: "workspace-test".to_string(),
|
identity: "workspace-test".to_string(),
|
||||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||||
},
|
},
|
||||||
availability:
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
|
||||||
state: "idle".to_string(),
|
state: "idle".to_string(),
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
|
|||||||
@@ -6883,9 +6883,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
params![record.workspace_id, record.ticket_id],
|
params![record.workspace_id, record.ticket_id],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|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(
|
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(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+356
-330
@@ -3845,6 +3845,9 @@
|
|||||||
},
|
},
|
||||||
"subject_ref": {
|
"subject_ref": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket_item_revision": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -3852,6 +3855,7 @@
|
|||||||
"event_id",
|
"event_id",
|
||||||
"sequence",
|
"sequence",
|
||||||
"subject_ref",
|
"subject_ref",
|
||||||
|
"ticket_item_revision",
|
||||||
"requested_by",
|
"requested_by",
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"created_at"
|
"created_at"
|
||||||
@@ -3897,6 +3901,9 @@
|
|||||||
},
|
},
|
||||||
"subject_ref": {
|
"subject_ref": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket_item_revision": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -3905,6 +3912,7 @@
|
|||||||
"sequence",
|
"sequence",
|
||||||
"request_event_id",
|
"request_event_id",
|
||||||
"subject_ref",
|
"subject_ref",
|
||||||
|
"ticket_item_revision",
|
||||||
"decision",
|
"decision",
|
||||||
"body",
|
"body",
|
||||||
"findings",
|
"findings",
|
||||||
@@ -6303,6 +6311,9 @@
|
|||||||
},
|
},
|
||||||
"subject_ref": {
|
"subject_ref": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket_item_revision": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6310,6 +6321,7 @@
|
|||||||
"sequence",
|
"sequence",
|
||||||
"request_event_id",
|
"request_event_id",
|
||||||
"subject_ref",
|
"subject_ref",
|
||||||
|
"ticket_item_revision",
|
||||||
"decision",
|
"decision",
|
||||||
"body",
|
"body",
|
||||||
"findings",
|
"findings",
|
||||||
@@ -8955,6 +8967,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"merge_requests": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketMergeRequestSummary"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9038,6 +9056,7 @@
|
|||||||
"assignments",
|
"assignments",
|
||||||
"assignment_diagnostics",
|
"assignment_diagnostics",
|
||||||
"action_eligibility",
|
"action_eligibility",
|
||||||
|
"merge_requests",
|
||||||
"evidence",
|
"evidence",
|
||||||
"record_source"
|
"record_source"
|
||||||
],
|
],
|
||||||
@@ -10105,6 +10124,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"merge_requests": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketMergeRequestSummary"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -10172,7 +10197,8 @@
|
|||||||
"blocker_count",
|
"blocker_count",
|
||||||
"unresolved_blocker_count",
|
"unresolved_blocker_count",
|
||||||
"unresolved_review_count",
|
"unresolved_review_count",
|
||||||
"evidence"
|
"evidence",
|
||||||
|
"merge_requests"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -13919,7 +13945,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a"
|
"x-yoi-source-digest": "sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -19132,6 +19158,334 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/merge-requests/{id}/readiness": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "merge_request_readiness",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadinessResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/merge-requests/{id}/repair-source": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "merge_request_selector_repair",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairMergeRequestSelectorRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PublicMergeRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/merge-requests/{id}/thread": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "merge_request_thread",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": true,
|
||||||
|
"in": "query",
|
||||||
|
"name": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestThreadQuery"
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Array_of_MergeRequestThreadEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/merge-requests/{merge_request_id}": {
|
"/api/w/{workspace_id}/merge-requests/{merge_request_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "merge_request_show",
|
"operationId": "merge_request_show",
|
||||||
@@ -29542,334 +29896,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/readiness": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "merge_request_readiness",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "workspace_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/MergeRequestReadinessResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful response"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearerAuth": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"browserSession": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "merge_request_selector_repair",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "workspace_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepairMergeRequestSelectorRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PublicMergeRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful response"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"browserSession": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/thread": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "merge_request_thread",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "workspace_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"explode": true,
|
|
||||||
"in": "query",
|
|
||||||
"name": "query",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/MergeRequestThreadQuery"
|
|
||||||
},
|
|
||||||
"style": "form"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Array_of_MergeRequestThreadEvent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful response"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearerAuth": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"browserSession": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans": {
|
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "ticket_orchestration_plan_record",
|
"operationId": "ticket_orchestration_plan_record",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
review = {
|
review = {
|
||||||
instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
|
instructions = "Use the explicitly addressed current Merge Request as review authority. Call `ShowMergeRequest` with its ID and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and both the Ticket id and selected Merge Request id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and current Ticket item revision and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
|
||||||
transitions = {
|
transitions = {
|
||||||
approved = {
|
approved = {
|
||||||
target = "complete";
|
target = "complete";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
## Merge Request workflow
|
## Merge Request workflow
|
||||||
|
|
||||||
The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and completion evidence. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, and `CompleteMergeRequest`.
|
The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`, and `CompleteTicket`.
|
||||||
|
|
||||||
An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`.
|
An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`.
|
||||||
|
|
||||||
A review verdict is valid only for the exact provider-resolved source ref captured by `ReviewRequested`. Moving the source ref requires a fresh review of the new exact source. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority.
|
A review verdict is valid only for the exact provider-resolved source ref, selected Merge Request, and current Ticket item revision captured by `ReviewRequested`. Moving the source ref or substantively rescoping the Ticket requires a fresh review. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. `CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. After every linked required result is integrated, `CompleteTicket` revalidates the exact linked-MR set, current Ticket revision, Reviewer approvals, state, and assignment before completing the Ticket.
|
||||||
|
|
||||||
Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or completion, stop and reread current authority rather than reusing stale evidence.
|
Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or completion, stop and reread current authority rather than reusing stale evidence.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
||||||
|
|
||||||
Use the existing Merge Request as the routine authority for review requests, verdicts, fixes, and rereview cycles. `OpenMergeRequest` creates the one selector-based Merge Request; if one is already open, use `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
|
Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
|
||||||
|
|
||||||
Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence.
|
Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence.
|
||||||
|
|
||||||
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Only the Ticket's sole `read_write` target is an implementation/publish/Merge Request repository; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write.
|
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Every declared `read_write` target is an authorized implementation/publish/Merge Request repository, but writable targets are not automatically mandatory deliverables; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write.
|
||||||
|
|
||||||
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
|
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ You are the Ticket Orchestrator role.
|
|||||||
|
|
||||||
{% include "common.git" %}
|
{% include "common.git" %}
|
||||||
|
|
||||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable semantic aliases such as `checkout` for the sole `read_write` target and repository-derived aliases for `read_only` targets. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from the sole `read_write` target's Ticket work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
|
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable repository-derived aliases and grant Backend-derived write capability to every `read_write` target. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from any required `read_write` target work branch, guarded integration of the approved linked Merge Requests, and guarded Ticket completion to the Workspace Orchestrator by default. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
|
||||||
|
|
||||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||||
|
|
||||||
Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket`, `ShowMergeRequest`, and `CheckMergeRequestReadiness` immediately before guarded integration. Require the Merge Request source selector to remain on the exact reviewed commit: source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target; it does not require a replacement Merge Request, an add-revision operation, or a fresh integration branch.
|
Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket item revision, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target.
|
||||||
|
|
||||||
Before integration, run `CheckMergeRequestReadiness` and reread the Ticket, current assignment, and exact approved subject. Treat the provider-resolved `selector_from` as the `merge_from` branch and `selector_to` as the `merge_to` branch. Obtain their exact approved source hash and `target_ref_before`, selected merge strategy, and approval event from authoritative Merge Request evidence.
|
Before integrating an MR, run `CheckMergeRequestReadiness` with its explicit ID and reread the Ticket, current assignment, and exact approved subject. Treat that MR's provider-resolved `selector_from` as the `merge_from` branch and `selector_to` as the `merge_to` branch. Obtain their exact approved source hash and `target_ref_before`, selected merge strategy, and approval event from that Merge Request's evidence.
|
||||||
|
|
||||||
Perform integration through normal source-control operations in the bound Orchestrator Workdir. Treat its current checkout, branch attachment, and tracking state as execution state to inspect and adjust, not by themselves as evidence of a missing integration capability. Ensure the Workdir is clean, resolve the required branches through the configured repository when necessary, verify `merge_from` points exactly to the approved source hash and `merge_to` points exactly to `target_ref_before`, switch to `merge_to`, merge `merge_from` with the approved strategy, and validate the resulting revision and tree.
|
Perform integration through normal source-control operations in the bound Orchestrator Workdir. Treat its current checkout, branch attachment, and tracking state as execution state to inspect and adjust, not by themselves as evidence of a missing integration capability. Ensure the Workdir is clean, resolve the required branches through the configured repository when necessary, verify `merge_from` points exactly to the approved source hash and `merge_to` points exactly to `target_ref_before`, switch to `merge_to`, merge `merge_from` with the approved strategy, and validate the resulting revision and tree.
|
||||||
|
|
||||||
Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider.
|
Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider.
|
||||||
|
|
||||||
After the push, verify the repository provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with the before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies an already-applied repository integration; it does not update the branch itself.
|
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated.
|
||||||
|
|
||||||
If the repository push succeeds but completion recording fails, do not push again or invent a new result. Retry the same completion operation and evidence: while no completion event exists, the Server requires the target to remain at the exact `target_ref_after` before it records `MergeResult`, moves the Ticket to `done`, and closes the current assignment atomically. Once that exact operation is recorded, later target movement does not invalidate an idempotent replay of the recorded result. Before recording, any other observed target is a stale/conflicting completion and must fail closed.
|
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR set, verify the Reviewer evidence still judges the requirements satisfied, then call `CompleteTicket` with that snapshot. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
|
||||||
|
|
||||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||||
|
|
||||||
@@ -24,6 +24,6 @@ Workspace roots, cwd, profile selector, and launch-prompt configuration are cont
|
|||||||
|
|
||||||
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||||
|
|
||||||
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After `CompleteMergeRequest` and Ticket completion, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
||||||
|
|
||||||
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
||||||
|
|
||||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
|
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, and `ticket_item_revision` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of the complete linked result set without reusing another MR's approval. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
|
||||||
|
|
||||||
Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. A verdict applies only to that captured source ref; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority.
|
Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. A verdict applies only to that captured source ref; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
|
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a)
|
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a)
|
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
|
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
@@ -49,7 +49,7 @@ export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic |
|
|||||||
|
|
||||||
export type MergeRequestState = "open" | "merged" | "closed";
|
export type MergeRequestState = "open" | "merged" | "closed";
|
||||||
|
|
||||||
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
||||||
|
|
||||||
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contrib
|
|||||||
|
|
||||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||||
|
|
||||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||||
|
|
||||||
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a)
|
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
|||||||
"item_revision",
|
"item_revision",
|
||||||
"linked_objectives",
|
"linked_objectives",
|
||||||
"merge_request",
|
"merge_request",
|
||||||
|
"merge_requests",
|
||||||
"priority",
|
"priority",
|
||||||
"queued_at",
|
"queued_at",
|
||||||
"queued_by",
|
"queued_by",
|
||||||
@@ -769,6 +770,11 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
|||||||
`${label}.linked_objectives`,
|
`${label}.linked_objectives`,
|
||||||
parseObjectiveLink,
|
parseObjectiveLink,
|
||||||
),
|
),
|
||||||
|
merge_requests: array(
|
||||||
|
item.merge_requests,
|
||||||
|
`${label}.merge_requests`,
|
||||||
|
parseTicketMergeRequestSummary,
|
||||||
|
),
|
||||||
merge_request: item.merge_request === undefined
|
merge_request: item.merge_request === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: item.merge_request === null
|
: item.merge_request === null
|
||||||
@@ -1082,7 +1088,17 @@ function parseMergeRequestThreadEvent(
|
|||||||
sequence: integer(item.sequence, `${label}.sequence`),
|
sequence: integer(item.sequence, `${label}.sequence`),
|
||||||
};
|
};
|
||||||
if (kind === "review_requested") {
|
if (kind === "review_requested") {
|
||||||
exact(item, [...base, "requested_by", "reviewer", "subject_ref"], label);
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
...base,
|
||||||
|
"requested_by",
|
||||||
|
"reviewer",
|
||||||
|
"subject_ref",
|
||||||
|
"ticket_item_revision",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...eventBase,
|
...eventBase,
|
||||||
kind,
|
kind,
|
||||||
@@ -1092,6 +1108,10 @@ function parseMergeRequestThreadEvent(
|
|||||||
),
|
),
|
||||||
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
||||||
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
||||||
|
ticket_item_revision: string(
|
||||||
|
item.ticket_item_revision,
|
||||||
|
`${label}.ticket_item_revision`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === "review") {
|
if (kind === "review") {
|
||||||
@@ -1103,6 +1123,7 @@ function parseMergeRequestThreadEvent(
|
|||||||
"request_event_id",
|
"request_event_id",
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"subject_ref",
|
"subject_ref",
|
||||||
|
"ticket_item_revision",
|
||||||
], label);
|
], label);
|
||||||
const decision = string(item.decision, `${label}.decision`);
|
const decision = string(item.decision, `${label}.decision`);
|
||||||
if (!["approve", "request_changes"].includes(decision)) {
|
if (!["approve", "request_changes"].includes(decision)) {
|
||||||
@@ -1120,6 +1141,10 @@ function parseMergeRequestThreadEvent(
|
|||||||
),
|
),
|
||||||
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`),
|
||||||
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
|
||||||
|
ticket_item_revision: string(
|
||||||
|
item.ticket_item_revision,
|
||||||
|
`${label}.ticket_item_revision`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === "review_revoked") {
|
if (kind === "review_revoked") {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
const loadedRepositories = $derived(data.repositories.data);
|
const loadedRepositories = $derived(data.repositories.data);
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
const mergeRequest = $derived(ticket.merge_request);
|
const mergeRequests = $derived(ticket.merge_requests);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editTitle = $state(loadedTicket.title);
|
let editTitle = $state(loadedTicket.title);
|
||||||
let editBody = $state(loadedTicket.body);
|
let editBody = $state(loadedTicket.body);
|
||||||
@@ -682,28 +682,32 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<section class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
<header><h2>Merge Request</h2></header>
|
<header><h2>Merge Requests</h2></header>
|
||||||
{#if mergeRequest}
|
{#if mergeRequests.length > 0}
|
||||||
<p><strong>{mergeRequest.state}</strong></p>
|
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
|
||||||
<p>
|
<article class="ticket-control-card">
|
||||||
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
|
||||||
to <code>{mergeRequest.selector_to}</code>
|
<p>
|
||||||
</p>
|
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
||||||
{#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref}
|
to <code>{mergeRequest.selector_to}</code>
|
||||||
<p><strong>Source review:</strong> {mergeRequest.review_status} for exact ref <code>{mergeRequest.current_subject_ref}</code></p>
|
</p>
|
||||||
{:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref}
|
{#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref}
|
||||||
<p><strong>Fresh source review required:</strong> selector_from moved from <code>{mergeRequest.review_subject_ref}</code> to <code>{mergeRequest.current_subject_ref}</code>.</p>
|
<p><strong>Source review:</strong> {mergeRequest.review_status} for exact ref <code>{mergeRequest.current_subject_ref}</code></p>
|
||||||
{:else if mergeRequest.current_subject_ref}
|
{:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref}
|
||||||
<p><strong>Fresh source review required:</strong> no effective verdict exists for <code>{mergeRequest.current_subject_ref}</code>.</p>
|
<p><strong>Fresh source review required:</strong> selector_from moved from <code>{mergeRequest.review_subject_ref}</code> to <code>{mergeRequest.current_subject_ref}</code>.</p>
|
||||||
{:else}
|
{:else if mergeRequest.current_subject_ref}
|
||||||
<p><strong>Source review unavailable:</strong> selector_from is unresolved.</p>
|
<p><strong>Fresh source review required:</strong> no effective verdict exists for <code>{mergeRequest.current_subject_ref}</code>.</p>
|
||||||
{/if}
|
{:else}
|
||||||
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
|
<p><strong>Source review unavailable:</strong> selector_from is unresolved.</p>
|
||||||
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
|
{/if}
|
||||||
<a
|
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
|
||||||
class="workspace-secondary-button"
|
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
|
||||||
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
<a
|
||||||
>Open Merge Request</a>
|
class="workspace-secondary-button"
|
||||||
|
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
||||||
|
>Open Merge Request</a>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user