1 | |
2 |
|
3 | function authHeader(auth: RT.Servicenow) { |
4 | return auth.token |
5 | ? `Bearer ${auth.token}` |
6 | : `Basic ${btoa(`${auth.username}:${auth.password}`)}` |
7 | } |
8 |
|
9 | |
10 | function value(field: any) { |
11 | return field != null && typeof field === "object" ? field.value : field |
12 | } |
13 |
|
14 | function label(field: any) { |
15 | return field != null && typeof field === "object" |
16 | ? field.display_value |
17 | : field |
18 | } |
19 |
|
20 | |
21 | |
22 | |
23 | const LIFECYCLE = ["-5", "-4", "-3", "-2", "-1", "0", "3"] |
24 | const CLOSED = "3" |
25 | const REVIEW = "0" |
26 | const MAX_STEPS = 10 |
27 |
|
28 | |
29 | * Close Change Request |
30 | * 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. |
31 | */ |
32 | export async function main( |
33 | auth: RT.Servicenow, |
34 | sys_id: string, |
35 | close_code: string, |
36 | close_notes: string |
37 | ) { |
38 | const url = new URL( |
39 | `${auth.instance_url}/api/sn_chg_rest/change/${encodeURIComponent(sys_id)}` |
40 | ) |
41 | const headers = { |
42 | Authorization: authHeader(auth), |
43 | Accept: "application/json", |
44 | } |
45 |
|
46 | async function request(target: URL, init?: RequestInit) { |
47 | const response = await fetch(target, init ?? { headers }) |
48 | if (!response.ok) { |
49 | throw new Error(`${response.status} ${await response.text()}`) |
50 | } |
51 | return ((await response.json()) as { result: any }).result |
52 | } |
53 |
|
54 | function patch(body: { [key: string]: any }) { |
55 | return request(url, { |
56 | method: "PATCH", |
57 | headers: { ...headers, "Content-Type": "application/json" }, |
58 | body: JSON.stringify(body), |
59 | }) |
60 | } |
61 |
|
62 | const steps: { from: string; to: string; result_state: string }[] = [] |
63 | let record = await request(url) |
64 | let closeFieldsSet = false |
65 |
|
66 | function stop(reason: string, extra: { [key: string]: any } = {}) { |
67 | return { |
68 | closed: false, |
69 | sys_id: value(record.sys_id), |
70 | number: value(record.number), |
71 | state: String(value(record.state)), |
72 | state_label: label(record.state), |
73 | approval: value(record.approval), |
74 | reason, |
75 | steps, |
76 | ...extra, |
77 | } |
78 | } |
79 |
|
80 | for (let i = 0; i < MAX_STEPS; i++) { |
81 | const current = String(value(record.state)) |
82 | if (current === CLOSED) { |
83 | return { |
84 | closed: true, |
85 | sys_id: value(record.sys_id), |
86 | number: value(record.number), |
87 | state: current, |
88 | state_label: label(record.state), |
89 | steps, |
90 | } |
91 | } |
92 | const currentIndex = LIFECYCLE.indexOf(current) |
93 | if (currentIndex === -1) { |
94 | return stop( |
95 | `State ${label(record.state)} (${current}) is not on the New -> Closed path (canceled, or a custom state).` |
96 | ) |
97 | } |
98 |
|
99 | |
100 | |
101 | if (current === REVIEW && !closeFieldsSet) { |
102 | record = await patch({ close_code, close_notes }) |
103 | closeFieldsSet = true |
104 | } |
105 |
|
106 | const next = await request(new URL(`${url}/nextstates`)) |
107 | const available: string[] = (next.available_states ?? []).map(String) |
108 | |
109 | const transitions: any[] = (next.state_transitions ?? []).flat() |
110 | const allowed = |
111 | transitions.length > 0 |
112 | ? available.filter((s) => |
113 | transitions.some( |
114 | (t) => String(t.to_state) === s && t.transition_available |
115 | ) |
116 | ) |
117 | : available |
118 | const forward = allowed |
119 | .filter((s) => LIFECYCLE.indexOf(s) > currentIndex) |
120 | .sort((a, b) => LIFECYCLE.indexOf(a) - LIFECYCLE.indexOf(b)) |
121 |
|
122 | if (forward.length === 0) { |
123 | const blocked = transitions |
124 | .filter( |
125 | (t) => |
126 | !t.transition_available && |
127 | LIFECYCLE.indexOf(String(t.to_state)) > currentIndex |
128 | ) |
129 | .map((t) => ({ |
130 | to_state: String(t.to_state), |
131 | to_state_label: next.state_label?.[t.to_state], |
132 | failed_conditions: (t.conditions ?? []) |
133 | .filter((c: any) => !c.passed) |
134 | .map((c: any) => c.condition?.name), |
135 | })) |
136 | const approval = value(record.approval) |
137 | const reason = |
138 | approval === "requested" |
139 | ? `Approval pending: the change is in ${label(record.state)} and waits for its approvers.` |
140 | : approval === "rejected" |
141 | ? `Approval rejected: the change is in ${label(record.state)}.` |
142 | : `No forward state is available from ${label(record.state)} (${current}).` |
143 | return stop(reason, { |
144 | available_states: available, |
145 | blocked_transitions: blocked, |
146 | }) |
147 | } |
148 |
|
149 | const target = forward.includes(CLOSED) ? CLOSED : forward[0] |
150 | const updated = await patch( |
151 | target === CLOSED |
152 | ? { state: target, close_code, close_notes } |
153 | : { state: target } |
154 | ) |
155 | const ignored: string[] = updated.__meta?.ignoredFields ?? [] |
156 | steps.push({ |
157 | from: current, |
158 | to: target, |
159 | result_state: String(value(updated.state)), |
160 | }) |
161 | if (ignored.includes("state") || String(value(updated.state)) === current) { |
162 | record = updated |
163 | return stop( |
164 | `ServiceNow did not apply the move from ${current} to ${target}; the change is still in ${label(updated.state)}.`, |
165 | { ignored_fields: ignored } |
166 | ) |
167 | } |
168 | record = updated |
169 | } |
170 |
|
171 | return stop(`Stopped after ${MAX_STEPS} transitions without reaching Closed.`) |
172 | } |
173 |
|