renovate
Version:
Automated dependency updates. Flexible so you don't need to be.
270 lines (269 loc) • 9.29 kB
JavaScript
import { GlobalConfig } from "../../../config/global.js";
import { sanitize } from "../../../util/sanitize.js";
import { logger } from "../../../logger/index.js";
import { Lazy } from "../../../util/lazy.js";
import { workItemTrackingApi } from "./azure-got-wrapper.js";
import { getWorkItemTitle } from "./util.js";
import { isNonEmptyString } from "@sindresorhus/is";
//#region lib/modules/platform/azure/issue.ts
const defaultOpenState = "New";
const defaultClosedState = "Closed";
const openCategories = ["Proposed", "InProgress"];
const closedCategories = [
"Completed",
"Resolved",
"Removed"
];
/**
* Return the names of the states whose category is one of `categories`,
* preserving the order Azure DevOps returns them in (workflow order).
*/
function namesByCategory(stateColors, categories) {
return stateColors.filter((s) => s.category && categories.includes(s.category) && s.name).map((s) => s.name);
}
var IssueService = class {
config;
workItemStates;
constructor(config) {
this.config = config;
this.workItemStates = new Lazy(() => this.resolveWorkItemStates());
}
/**
* Resolve the concrete open/closed state names for the `Issue` work item type
* from its process, so Renovate does not depend on hardcoded state names that
* only exist in some Azure DevOps processes. Falls back to the historical
* `New`/`Closed` values if the states cannot be fetched.
*/
async resolveWorkItemStates() {
const states = {
open: defaultOpenState,
closed: defaultClosedState,
closedNames: /* @__PURE__ */ new Set([defaultClosedState])
};
try {
const stateColors = await (await workItemTrackingApi()).getWorkItemTypeStates(this.config.project, this.config.workItemType);
if (stateColors?.length) {
const openNames = namesByCategory(stateColors, openCategories);
const completed = stateColors.find((s) => s.category === "Completed" && s.name);
const closedNames = namesByCategory(stateColors, closedCategories);
states.open = openNames[0] ?? stateColors[0].name ?? states.open;
if (closedNames.length) {
states.closed = completed?.name ?? closedNames[0];
states.closedNames = new Set(closedNames);
}
}
} catch (err) {
logger.debug({ err }, "Azure: could not resolve work item states, using default state names");
}
return states;
}
async findIssue(title) {
logger.debug(`findIssue(${title})`);
try {
const finalTitle = getWorkItemTitle(title, this.config.repository);
return (await this.getIssueList(finalTitle))[0] ?? null;
} catch (err) {
logger.error({ err }, "Error finding issue");
return null;
}
}
/**
* The work item type names the project's process defines. Some processes
* (e.g. Scrum) do not define `Issue`, in which case creating one fails;
* checking up front lets us log an actionable message (including the types
* that *are* available) instead of a cryptic error.
*/
async getWorkItemTypeNames(azureApiWit) {
return (await azureApiWit.getWorkItemTypes(this.config.project)).map((t) => t.name).filter(isNonEmptyString);
}
async getIssueList(titleFilter) {
logger.debug("getIssueList()");
try {
const azureApiWit = await workItemTrackingApi();
let wiql = `
SELECT [System.Id]
FROM WorkItems
WHERE [System.TeamProject] = '${this.config.project}'
`;
if (titleFilter) {
const escapedTitle = titleFilter.replace(/'/g, "''");
wiql += ` AND [System.Title] = '${escapedTitle}'`;
}
const result = await azureApiWit.queryByWiql({ query: wiql });
if (!result.workItems?.length) {
logger.debug("getIssueList() no work items found");
return [];
}
const workItemIds = result.workItems.map((wi) => wi.id);
const workItems = await azureApiWit.getWorkItems(workItemIds, [
"System.Id",
"System.Title",
"System.State",
"System.Description",
"System.CreatedDate",
"System.ChangedDate"
]);
const { closedNames } = await this.workItemStates.getValue();
return workItems.map((wi) => ({
number: wi.id,
title: wi.fields["System.Title"],
state: closedNames.has(wi.fields["System.State"]) ? "closed" : "open",
body: wi.fields["System.Description"],
createdAt: wi.fields["System.CreatedDate"],
lastModified: wi.fields["System.ChangedDate"]
}));
} catch (err) {
logger.warn({ err }, "Error fetching issue list");
return [];
}
}
async ensureIssueClosing(title) {
logger.debug(`ensureIssueClosing(${title})`);
try {
const issue = await this.findIssue(title);
if (issue?.state === "open" && issue.number) {
const azureApiWit = await workItemTrackingApi();
const { closed } = await this.workItemStates.getValue();
await azureApiWit.updateWorkItem(void 0, [{
op: "replace",
path: "/fields/System.State",
value: closed
}], issue.number, this.config.project);
logger.debug(`Closed issue #${issue.number}: ${title}`);
}
} catch (err) {
logger.error({ err }, "Error closing issue");
}
}
async ensureIssue({ title, body, once = false, shouldReOpen = true }) {
logger.debug(`ensureIssue()`);
try {
const azureApiWit = await workItemTrackingApi();
const finalTitle = getWorkItemTitle(title, this.config.repository);
const issues = await this.getIssueList(finalTitle);
const { open, closed } = await this.workItemStates.getValue();
const openIssues = issues.filter((issue) => issue.state === "open");
if (openIssues.length > 1) for (let i = 1; i < openIssues.length; i++) {
const issueNumber = openIssues[i].number;
if (issueNumber) {
await azureApiWit.updateWorkItem(void 0, [{
op: "replace",
path: "/fields/System.State",
value: closed
}], issueNumber, this.config.project);
logger.info(`Closed duplicate issue #${issueNumber}`);
}
}
const existingIssue = openIssues[0] ?? issues.find((issue) => issue.state === "closed");
if (existingIssue) {
if (existingIssue.state === "closed" && once) {
logger.debug("Issue already closed - skipping update");
return null;
}
if (existingIssue.state === "closed" && shouldReOpen) {
if (!existingIssue.number) {
logger.warn("Cannot reopen issue without number");
return null;
}
await azureApiWit.updateWorkItem(void 0, [
{
op: "replace",
path: "/fields/System.State",
value: open
},
{
op: "replace",
path: "/fields/System.Title",
value: finalTitle
},
{
op: "replace",
path: "/fields/System.Description",
value: sanitize(body)
},
{
op: "replace",
path: "/multilineFieldsFormat/System.Description",
value: "Markdown"
}
], existingIssue.number, this.config.project);
logger.debug(`Reopened issue #${existingIssue.number}`);
return "updated";
}
if (existingIssue.state === "open") {
if (existingIssue.title !== finalTitle || existingIssue.body !== body) {
if (!existingIssue.number) {
logger.warn("Cannot update issue without number");
return null;
}
await azureApiWit.updateWorkItem(void 0, [
{
op: "replace",
path: "/fields/System.Title",
value: finalTitle
},
{
op: "replace",
path: "/fields/System.Description",
value: sanitize(body)
},
{
op: "replace",
path: "/multilineFieldsFormat/System.Description",
value: "Markdown"
}
], existingIssue.number, this.config.project);
logger.debug(`Updated issue #${existingIssue.number}`);
return "updated";
}
logger.debug(`Issue #${existingIssue.number} is already up-to-date`);
return "updated";
}
}
const availableTypes = await this.getWorkItemTypeNames(azureApiWit);
if (!availableTypes.includes(this.config.workItemType)) {
logger.warn({
workItemType: this.config.workItemType,
availableTypes,
project: this.config.project,
documentationUrl: `${GlobalConfig.get("productLinks").documentation}configuration-options/#azureworkitemtype`
}, "Azure: work item type does not exist in project (or the token lacks permission to it); skipping issue. The Dependency Dashboard needs a process that defines this work item type. Set one your project defines via the `azureWorkItemType` repo config option.");
return null;
}
const newWorkItem = await azureApiWit.createWorkItem(void 0, [
{
op: "add",
path: "/fields/System.WorkItemType",
value: this.config.workItemType
},
{
op: "add",
path: "/fields/System.Title",
value: finalTitle
},
{
op: "add",
path: "/fields/System.Description",
value: sanitize(body)
},
{
op: "add",
path: "/multilineFieldsFormat/System.Description",
value: "Markdown"
}
], this.config.project, this.config.workItemType);
if (!newWorkItem?.id) {
logger.warn("Azure: work item creation returned no result; skipping issue");
return null;
}
logger.debug(`Created new issue #${newWorkItem.id}`);
return "created";
} catch (err) {
logger.warn({ err }, "Error ensuring issue");
return null;
}
}
};
//#endregion
export { IssueService };
//# sourceMappingURL=issue.js.map