github.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /** GitHub transport, Issue/Project readers, and Project membership and field writes. */
  2. import process from 'node:process'
  3. import config from './config.json' with { type: 'json' }
  4. const API_VERSION = '2026-03-10'
  5. function token() {
  6. const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
  7. if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
  8. return value
  9. }
  10. function projectToken() {
  11. return process.env.PROJECT_TOKEN || token()
  12. }
  13. /**
  14. * Send one GitHub REST request; HTTP failures retain the method, path, status, and body.
  15. * @param {string} path API path including any query string.
  16. * @param {object} options Fetch options; allow404 returns null for a 404.
  17. * @returns {Promise<object|null>} Decoded JSON, or null for an allowed 404 or a 204.
  18. */
  19. export async function api(path, options = {}) {
  20. const { allow404 = false, ...requestOptions } = options
  21. const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
  22. ...requestOptions,
  23. headers: {
  24. Accept: 'application/vnd.github+json',
  25. Authorization: `Bearer ${token()}`,
  26. 'X-GitHub-Api-Version': API_VERSION,
  27. 'User-Agent': 'dsh-issue-policy',
  28. ...options.headers,
  29. },
  30. })
  31. if (allow404 && response.status === 404) return null
  32. if (!response.ok) {
  33. const body = await response.text()
  34. throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
  35. }
  36. if (response.status === 204) return null
  37. return response.json()
  38. }
  39. /**
  40. * Send one Project-authenticated GraphQL request; reject joined GraphQL error messages.
  41. * @param {string} query GraphQL document.
  42. * @param {object} variables GraphQL variables.
  43. * @returns {Promise<object>} GraphQL data.
  44. */
  45. export async function graphql(query, variables) {
  46. const result = await api('/graphql', {
  47. method: 'POST',
  48. body: JSON.stringify({ query, variables }),
  49. headers: {
  50. Authorization: `Bearer ${projectToken()}`,
  51. 'Content-Type': 'application/json',
  52. },
  53. })
  54. if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
  55. return result.data
  56. }
  57. /**
  58. * Read one Issue together with its Project planning values.
  59. * @param {number} number Same-repository Issue number.
  60. * @param {string|null|undefined} status Optional known Project status.
  61. * @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
  62. */
  63. export async function issueSnapshot(number, status = undefined) {
  64. const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
  65. if (issue.pull_request) return null
  66. const context = await projectContext(number)
  67. return {
  68. number,
  69. nodeId: issue.node_id,
  70. labels: issue.labels.map((label) => label.name),
  71. type: issue.type?.name ?? null,
  72. priority: context.item?.priorityValue?.name ?? null,
  73. status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
  74. state: issue.state,
  75. stateReason: issue.state_reason ?? null,
  76. }
  77. }
  78. /**
  79. * Read and validate the configured Project fields and one Issue’s membership.
  80. * @param {number} number Same-repository Issue number.
  81. * @param {boolean} includeStatusActor Include the latest matching status-event actor.
  82. * @param {boolean} includeStartDate Include and validate the Project Start Date field.
  83. * @returns {Promise<object>} Project, Issue, fields, optional item, and status actor; never writes.
  84. */
  85. export async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
  86. const data = await graphql(
  87. `query(
  88. $organization: String!
  89. $repository: String!
  90. $number: Int!
  91. $project: Int!
  92. $includeStatusActor: Boolean!
  93. $includeStartDate: Boolean!
  94. $priorityField: String!
  95. $startDateField: String!
  96. ) {
  97. organization(login: $organization) {
  98. projectV2(number: $project) {
  99. id
  100. title
  101. fields(first: 50) {
  102. nodes {
  103. ... on ProjectV2Field {
  104. id
  105. name
  106. dataType
  107. isIssueField
  108. }
  109. ... on ProjectV2SingleSelectField {
  110. id
  111. name
  112. dataType
  113. isIssueField
  114. options { id name }
  115. }
  116. }
  117. }
  118. }
  119. }
  120. repository(owner: $organization, name: $repository) {
  121. issue(number: $number) {
  122. id
  123. timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
  124. @include(if: $includeStatusActor) {
  125. nodes {
  126. ... on ProjectV2ItemStatusChangedEvent {
  127. actor { login }
  128. project { id }
  129. status
  130. }
  131. }
  132. }
  133. projectItems(first: 20, includeArchived: true) {
  134. nodes {
  135. id
  136. project { id }
  137. fieldValueByName(name: "Status") {
  138. ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
  139. }
  140. priorityValue: fieldValueByName(name: $priorityField) {
  141. ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
  142. }
  143. startDateValue: fieldValueByName(name: $startDateField)
  144. @include(if: $includeStartDate) {
  145. ... on ProjectV2ItemFieldDateValue { date }
  146. }
  147. }
  148. }
  149. }
  150. }
  151. }`,
  152. {
  153. organization: config.organization,
  154. repository: config.repository,
  155. number,
  156. project: config.projectNumber,
  157. includeStatusActor,
  158. includeStartDate,
  159. priorityField: config.priorityField,
  160. startDateField: config.startDateField,
  161. },
  162. )
  163. const project = data.organization?.projectV2
  164. const issue = data.repository?.issue
  165. if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
  166. if (!issue) throw new Error(`#${number} 不存在`)
  167. const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
  168. if (!statusField) throw new Error('Project 缺少 Status 字段')
  169. const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
  170. if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
  171. if (priorityField.dataType !== 'SINGLE_SELECT') {
  172. throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
  173. }
  174. if (priorityField.isIssueField) {
  175. throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
  176. }
  177. const startDateField = includeStartDate
  178. ? project.fields.nodes.find((field) => field?.name === config.startDateField)
  179. : null
  180. if (includeStartDate && !startDateField) {
  181. throw new Error(`Project 缺少 ${config.startDateField} 字段`)
  182. }
  183. if (startDateField && startDateField.dataType !== 'DATE') {
  184. throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
  185. }
  186. if (startDateField?.isIssueField) {
  187. throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
  188. }
  189. const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
  190. const latestStatusEvent = issue.timelineItems?.nodes
  191. ?.filter((event) => event?.project?.id === project.id)
  192. .at(-1)
  193. const statusActor =
  194. latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
  195. ? (latestStatusEvent.actor?.login ?? null)
  196. : null
  197. return { project, issue, statusField, priorityField, startDateField, item, statusActor }
  198. }
  199. /**
  200. * Read Project membership and add the Issue when absent.
  201. * @param {number} number Same-repository Issue number.
  202. * @param {boolean} includeStartDate Include and validate the Project Start Date field.
  203. * @returns {Promise<object>} Project context with an existing or newly added item.
  204. */
  205. export async function ensureProjectItem(number, includeStartDate = false) {
  206. const context = await projectContext(number, false, includeStartDate)
  207. if (context.item) return context
  208. const data = await graphql(
  209. `mutation($projectId: ID!, $contentId: ID!) {
  210. addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
  211. item { id }
  212. }
  213. }`,
  214. { projectId: context.project.id, contentId: context.issue.id },
  215. )
  216. return {
  217. ...context,
  218. item: {
  219. id: data.addProjectV2ItemById.item.id,
  220. fieldValueByName: null,
  221. priorityValue: null,
  222. startDateValue: null,
  223. },
  224. }
  225. }
  226. /**
  227. * Initialize one Issue's Project Start Date when it is empty.
  228. * @param {number} number Same-repository Issue number.
  229. * @param {string} date Date in YYYY-MM-DD form.
  230. * @returns {Promise<void>} Resolves after the conditional Project update.
  231. */
  232. export async function initializeIssueStartDate(number, date) {
  233. const context = await ensureProjectItem(number, true)
  234. if (context.item.startDateValue?.date) return
  235. await graphql(
  236. `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
  237. updateProjectV2ItemFieldValue(input: {
  238. projectId: $projectId,
  239. itemId: $itemId,
  240. fieldId: $fieldId,
  241. value: {date: $date}
  242. }) { projectV2Item { id } }
  243. }`,
  244. {
  245. projectId: context.project.id,
  246. itemId: context.item.id,
  247. fieldId: context.startDateField.id,
  248. date,
  249. },
  250. )
  251. }
  252. /**
  253. * Write the named Status option unless the supplied item already has it.
  254. * @param {object} context Project context with an item and Status options.
  255. * @param {string} status Configured Status option name.
  256. * @returns {Promise<void>} Resolves after the optional update; rejects an unknown option.
  257. */
  258. export async function updateStatus(context, status) {
  259. const option = context.statusField.options.find((candidate) => candidate.name === status)
  260. if (!option) throw new Error(`Status 不存在:${status}`)
  261. if (context.item.fieldValueByName?.name === status) return
  262. await graphql(
  263. `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
  264. updateProjectV2ItemFieldValue(input: {
  265. projectId: $projectId,
  266. itemId: $itemId,
  267. fieldId: $fieldId,
  268. value: {singleSelectOptionId: $optionId}
  269. }) { projectV2Item { id } }
  270. }`,
  271. {
  272. projectId: context.project.id,
  273. itemId: context.item.id,
  274. fieldId: context.statusField.id,
  275. optionId: option.id,
  276. },
  277. )
  278. }
  279. /**
  280. * Ensure Project membership before updating an Issue’s Status.
  281. * @param {number} number Same-repository Issue number.
  282. * @param {string} status Configured Status option name.
  283. * @returns {Promise<void>} Resolves after membership and Status updates.
  284. */
  285. export async function setStatus(number, status) {
  286. await updateStatus(await ensureProjectItem(number), status)
  287. }