0

Close change request

by
Published today

Move a change request to Closed with a close code and notes, stepping through the next states the Change Management API allows. Stops and reports where and why (e.g. approval pending) instead of forcing anything; never approves.

Script servicenow Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 6 hours ago
1
//native
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
// Change Management API fields come back as { value, display_value }.
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
// Default change_request state values, in lifecycle order:
21
// New, Assess, Authorize, Scheduled, Implement, Review, Closed.
22
// Canceled (4) is never chosen.
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
    // In Review, record the closure details first: a model's Review -> Closed
100
    // condition can depend on them.
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
    // Model-driven changes describe each transition; type-driven ones do not.
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