Skip to content

Commit

Permalink
ci: update
Browse files Browse the repository at this point in the history
  • Loading branch information
wenytang-ms committed Dec 4, 2024
1 parent 256bca9 commit 1d2acbe
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 4 deletions.
40 changes: 40 additions & 0 deletions .github/actions/issue-ado/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Issue Milestoned
description: Generate a work item for milestoned issue
inputs:
token:
description: GitHub token with issue, comment, and label read/write permissions
required: true
milestone-prefix:
description: 'the specific milestones prefix to create work item'
required: true
devops-org:
description: 'the org to create work item'
required: true
devops-projectId:
description: 'the project to create work item'
required: true
title-prefix:
description: 'the title prefix'
default: '[Github]'
bug-label:
description: the label to create bug item.
required: true
bug-area-path:
description: the area path to create bug item.
required: true
bug-iteration-path:
description: the iteration path to create bug item.
required: true
feature-label:
description: the label to create feature item. Input empty string to ignore feature
default: ''
feature-area-path:
description: the area path to create feature item.
default: ''
feature-iteration-path:
description: the iteration path to create feature item.
default: ''

runs:
using: 'node16'
main: 'index.js'
201 changes: 201 additions & 0 deletions .github/actions/issue-ado/azdo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import * as vm from 'azure-devops-node-api';
import * as nodeApi from 'azure-devops-node-api';
import * as WorkItemTrackingApi from 'azure-devops-node-api/WorkItemTrackingApi';
import * as WorkItemTrackingInterfaces from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
import {
JsonPatchDocument,
JsonPatchOperation,
Operation,
} from 'azure-devops-node-api/interfaces/common/VSSInterfaces';

class ItemInfo {
url: string;
id: number;
public constructor(url: string, id: number) {
this.url = url;
this.id = id;
}
}

export class DevopsClient {
token: string;
org: string;
projectId: string;
bugArea: string;
bugIteration: string;
featureArea: string;
featureIteration: string;

witApi?: WorkItemTrackingApi.IWorkItemTrackingApi;

constructor(
token: string,
org: string,
projectId: string,
bugArea: string,
bugIteration: string,
featureArea: string,
featureIteration: string,
) {
this.token = token;
this.org = org;
this.projectId = projectId;
this.bugArea = bugArea;
this.bugIteration = bugIteration;
this.featureArea = featureArea;
this.featureIteration = featureIteration;
}

public async init() {
let orgUrl = `https://dev.azure.com/${this.org}`;
const webApi: nodeApi.WebApi = await this.getApi(orgUrl);
this.witApi = await webApi.getWorkItemTrackingApi();
}

public async queryPreviousItem(description: string): Promise<ItemInfo | undefined> {
var query = `Select [System.Id] From WorkItems Where [System.Description] Contains Words '${description}' AND [System.HyperLinkCount] > 0 AND [State] <> 'Removed' order by [Microsoft.VSTS.Common.Priority] asc, [System.CreatedDate] desc`;
const items = await this.witApi!.queryByWiql({ query: query }, undefined, undefined, 1);
if (items.workItems?.length && items.workItems[0].id) {
const resp = await this.witApi?.getWorkItem(items.workItems[0].id);
return {
url: resp?._links?.html?.href,
id: items.workItems[0].id,
};
} else {
return undefined;
}
}

public async createFeatureItem(
titleValue: string,
asigneeValue: string | undefined,
tagsValue: string | undefined,
url: string,
sprintPath: string,
): Promise<WorkItemTrackingInterfaces.WorkItem> {
return this.createItem(
titleValue,
asigneeValue,
this.featureArea,
this.featureIteration,
tagsValue,
url,
'Feature',
sprintPath,
);
}

public async createBugItem(
titleValue: string,
asigneeValue: string | undefined,
tagsValue: string | undefined,
url: string,
sprintPath: string,
): Promise<WorkItemTrackingInterfaces.WorkItem> {
return this.createItem(
titleValue,
asigneeValue,
this.bugArea,
this.bugIteration,
tagsValue,
url,
'Bug',
sprintPath,
);
}

public async createItem(
titleValue: string,
asigneeValue: string | undefined,
areaValue: string,
iterationValue: string,
tagsValue: string | undefined,
url: string,
type: string,
sprintPath: string,
): Promise<WorkItemTrackingInterfaces.WorkItem> {
let document: JsonPatchOperation[] = [];

const title: JsonPatchOperation = {
path: '/fields/System.Title',
op: Operation.Add,
value: titleValue,
};
document.push(title);

if (asigneeValue) {
const asignee: JsonPatchOperation = {
path: '/fields/System.AssignedTo',
op: Operation.Add,
value: asigneeValue,
};
document.push(asignee);
}

const area: JsonPatchOperation = {
path: '/fields/System.AreaPath',
op: Operation.Add,
value: areaValue,
};
document.push(area);

const iteration: JsonPatchOperation = {
path: '/fields/System.IterationPath',
op: Operation.Add,
value: sprintPath ?? iterationValue,
};
document.push(iteration);

if (tagsValue) {
const tags: JsonPatchOperation = {
path: '/fields/System.Tags',
op: Operation.Add,
value: tagsValue,
};
document.push(tags);
}

const previous = await this.queryPreviousItem(url);
const description: JsonPatchOperation = {
path: '/fields/System.Description',
op: Operation.Add,
value: this.buildDescription(url, previous),
};
document.push(description);

const hyperLink: JsonPatchOperation = {
path: '/relations/-',
op: Operation.Add,
value: {
rel: 'Hyperlink',
url: url,
attributes: { comment: 'github issue link' },
},
};
document.push(hyperLink);

const item = await this.witApi!.createWorkItem(
undefined,
document as JsonPatchDocument,
this.projectId,
type,
);
return item;
}

private async getApi(serverUrl: string): Promise<vm.WebApi> {
let authHandler = vm.getHandlerFromToken(this.token);
let vsts: vm.WebApi = new vm.WebApi(serverUrl, authHandler);
await vsts.connect();
return vsts;
}

private buildDescription(url: string, addition?: ItemInfo): string {
var description = `<a href="${url}">${url}</a>`;
if (addition) {
description += `<br>There is an existing work item related to this issue<br>`;
description += `<div><a href="${addition.url}" data-vss-mention="version:1.0">#${addition.id}</a>&nbsp;</div>`;
}
return description;
}
}
101 changes: 101 additions & 0 deletions .github/actions/issue-ado/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { OctoKit, OctoKitIssue } from '../api/octokit';
import { Action } from '../common/Action';
import { DevopsClient } from './azdo';
import { getRequiredInput, safeLog } from '../common/utils';
import { context } from '@actions/github';
import { getInput } from '@actions/core';
import { getEmail, sendAlert } from '../teamsfx-utils/utils';
import * as WorkItemTrackingInterfaces from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
import { AzureCliCredential } from "@azure/identity";


const githubToken = getRequiredInput('token');
const milestonePrefix = getRequiredInput('milestone-prefix');
const org = getRequiredInput('devops-org');
const projectId = getRequiredInput('devops-projectId');
const titlePreix = getRequiredInput('title-prefix');
const bugLabel = getRequiredInput('bug-label');
const bugArea = getRequiredInput('bug-area-path');
const bugIteration = getRequiredInput('bug-iteration-path');
const featureLabel = getInput('feature-label');
const featureArea = getInput('feature-area-path');
const featureIteration = getInput('feature-iteration-path');

class Milestoned extends Action {
id = 'Milestoned';

async onMilestoned(issue: OctoKitIssue) {
const content = await issue.getIssue();
const milestoneTitle = content.milestone?.title ?? "";
let sprintPath = "";
if (content.milestone?.description) {
const match = content.milestone?.description.match(/Sprint path is:(.*)/);
if (match && match.length > 1) {
sprintPath = match[1];
}
}
safeLog(`the issue ${content.number} is created by label`);
let client = await this.createClient();
const asignee = getEmail(content.assignee);
if (!asignee) {
safeLog(`the issue ${content.number} assignee:${content.assignee} is not associated with email address, ignore.`);
const subject = '[Github Issue Alert] missing associated email address for assignee';
const issueLink = `https://github.com/OfficeDev/TeamsFx/issues/${content.number}`;
const fileLink = "https://github.com/OfficeDev/TeamsFx/blob/dev/.github/accounts.json";
const message = `There is a github issue <a>${issueLink}</a> milestoned with account <b>${content.assignee}</b> which is not associated with company email. Please check it and update the account mapping in the file <a>${fileLink}</a>.`;
safeLog(message);
sendAlert(subject, message);
}
const url = this.issueUrl(content.number);
const title = titlePreix + `[${milestoneTitle}]` + content.title;
let workItem: WorkItemTrackingInterfaces.WorkItem;
if (featureLabel && content.labels.includes(featureLabel)) {
safeLog(`issue labeled with ${featureLabel}. Feature work item will be created.`);
workItem = await client.createFeatureItem(title, asignee, undefined, url, sprintPath);
} else if (content.labels.includes(bugLabel)) {
safeLog(`issue labeled with ${bugLabel}. Bug work item will be created.`);
workItem = await client.createBugItem(title, asignee, undefined, url, sprintPath);
} else {
safeLog(
`issue labeled without feature label(${featureLabel}) and bug label(${bugLabel}). Default bug work item will be created.`,
);
workItem = await client.createBugItem(title, asignee, undefined, url, sprintPath);
}
safeLog(`finished to create work item.`);
const workItemUrl = workItem._links?.html?.href;
if (workItemUrl) {
await issue.postComment(`The issue is milestoned with sprint milestone ${milestoneTitle} and a work item created: ${workItemUrl}`);
} else {
safeLog(`no work item url found, ignore to post comment.`);
}
}
async onTriggered(_: OctoKit) {
const issueNumber = process.env.ISSUE_NUMBER;
safeLog(`start manually create work item for issue ${issueNumber}`);
const issue = new OctoKitIssue(githubToken, context.repo, { number: parseInt(issueNumber || "0") });
await this.onMilestoned(issue);
}

private async createClient() {
let credential = new AzureCliCredential();
const devopsToken = await credential.getToken("https://app.vssps.visualstudio.com/.default");

let client = new DevopsClient(
devopsToken.token,
org,
projectId,
bugArea,
bugIteration,
featureArea,
featureIteration,
);
await client.init();
return client;
}

private issueUrl(id: number) {
return `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${id}`;
}
}

new Milestoned().run(); // eslint-disable-line
8 changes: 4 additions & 4 deletions .github/workflows/issue-milestoned.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ name: Issue-milestoned

on:
issues:
types: [milestoned]
types: [milestoned, labeled]
workflow_dispatch: # allows to run manually for testing
inputs:
issueNumber:
description: 'specific issue number to test issue-milestoned action'
required: true
required: false

jobs:
main:
Expand Down Expand Up @@ -36,7 +36,7 @@ jobs:

- name: Copy action
run: |
cp -r .github/actions/issue-milestoned ./action-base/issue-milestoned
cp -r .github/actions/issue-ado ./action-base/issue-ado
cp -r .github/actions/teamsfx-utils ./action-base/teamsfx-utils
- name: Npm install dependencies
Expand All @@ -49,7 +49,7 @@ jobs:

- name: Create AZDO Item
id: create
uses: ./action-base/issue-milestoned
uses: ./action-base/issue-ado
with:
token: ${{secrets.GITHUB_TOKEN}}
milestone-prefix: "CY"
Expand Down

0 comments on commit 1d2acbe

Please sign in to comment.