//native
function authHeader(auth: RT.Servicenow) {
return auth.token
? `Bearer ${auth.token}`
: `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
// Change Management API fields come back as { value, display_value }.
function value(field: any) {
return field != null && typeof field === "object" ? field.value : field
}
function label(field: any) {
return field != null && typeof field === "object"
? field.display_value
: field
}
// Default change_request state values, in lifecycle order:
// New, Assess, Authorize, Scheduled, Implement, Review, Closed.
// Canceled (4) is never chosen.
const LIFECYCLE = ["-5", "-4", "-3", "-2", "-1", "0", "3"]
const CLOSED = "3"
const REVIEW = "0"
const MAX_STEPS = 10
/**
* Close Change Request
* Move a change request to Closed with a close code and close notes, stepping forward through the next states the Change Management API allows (GET .../nextstates, then PATCH /api/sn_chg_rest/change/{sys_id}; documented in the Yokohama and Brazil API references). Moving a change forward can request its approvals (as the Request approval button does); this action never approves anything. It stops and returns the current state and the reason (approval pending, a transition condition not met, or a state ServiceNow did not apply) instead of forcing the change. Assumes the default state values (Closed = 3, Review = 0); close_code defaults are successful, successful_issues and unsuccessful.
*/
export async function main(
auth: RT.Servicenow,
sys_id: string,
close_code: string,
close_notes: string
) {
const url = new URL(
`${auth.instance_url}/api/sn_chg_rest/change/${encodeURIComponent(sys_id)}`
)
const headers = {
Authorization: authHeader(auth),
Accept: "application/json",
}
async function request(target: URL, init?: RequestInit) {
const response = await fetch(target, init ?? { headers })
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}`)
}
return ((await response.json()) as { result: any }).result
}
function patch(body: { [key: string]: any }) {
return request(url, {
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
const steps: { from: string; to: string; result_state: string }[] = []
let record = await request(url)
let closeFieldsSet = false
function stop(reason: string, extra: { [key: string]: any } = {}) {
return {
closed: false,
sys_id: value(record.sys_id),
number: value(record.number),
state: String(value(record.state)),
state_label: label(record.state),
approval: value(record.approval),
reason,
steps,
...extra,
}
}
for (let i = 0; i < MAX_STEPS; i++) {
const current = String(value(record.state))
if (current === CLOSED) {
return {
closed: true,
sys_id: value(record.sys_id),
number: value(record.number),
state: current,
state_label: label(record.state),
steps,
}
}
const currentIndex = LIFECYCLE.indexOf(current)
if (currentIndex === -1) {
return stop(
`State ${label(record.state)} (${current}) is not on the New -> Closed path (canceled, or a custom state).`
)
}
// In Review, record the closure details first: a model's Review -> Closed
// condition can depend on them.
if (current === REVIEW && !closeFieldsSet) {
record = await patch({ close_code, close_notes })
closeFieldsSet = true
}
const next = await request(new URL(`${url}/nextstates`))
const available: string[] = (next.available_states ?? []).map(String)
// Model-driven changes describe each transition; type-driven ones do not.
const transitions: any[] = (next.state_transitions ?? []).flat()
const allowed =
transitions.length > 0
? available.filter((s) =>
transitions.some(
(t) => String(t.to_state) === s && t.transition_available
)
)
: available
const forward = allowed
.filter((s) => LIFECYCLE.indexOf(s) > currentIndex)
.sort((a, b) => LIFECYCLE.indexOf(a) - LIFECYCLE.indexOf(b))
if (forward.length === 0) {
const blocked = transitions
.filter(
(t) =>
!t.transition_available &&
LIFECYCLE.indexOf(String(t.to_state)) > currentIndex
)
.map((t) => ({
to_state: String(t.to_state),
to_state_label: next.state_label?.[t.to_state],
failed_conditions: (t.conditions ?? [])
.filter((c: any) => !c.passed)
.map((c: any) => c.condition?.name),
}))
const approval = value(record.approval)
const reason =
approval === "requested"
? `Approval pending: the change is in ${label(record.state)} and waits for its approvers.`
: approval === "rejected"
? `Approval rejected: the change is in ${label(record.state)}.`
: `No forward state is available from ${label(record.state)} (${current}).`
return stop(reason, {
available_states: available,
blocked_transitions: blocked,
})
}
const target = forward.includes(CLOSED) ? CLOSED : forward[0]
const updated = await patch(
target === CLOSED
? { state: target, close_code, close_notes }
: { state: target }
)
const ignored: string[] = updated.__meta?.ignoredFields ?? []
steps.push({
from: current,
to: target,
result_state: String(value(updated.state)),
})
if (ignored.includes("state") || String(value(updated.state)) === current) {
record = updated
return stop(
`ServiceNow did not apply the move from ${current} to ${target}; the change is still in ${label(updated.state)}.`,
{ ignored_fields: ignored }
)
}
record = updated
}
return stop(`Stopped after ${MAX_STEPS} transitions without reaching Closed.`)
}
Submitted by hugo989 7 hours ago