Skip to content

Commit

Permalink
Merge pull request #12882 from OfficeDev/wenyt/cd
Browse files Browse the repository at this point in the history
ci: update the branch policy
  • Loading branch information
eriolchan authored Dec 10, 2024
2 parents 517e4ab + 47fba16 commit 9bfb4e8
Show file tree
Hide file tree
Showing 11 changed files with 506 additions and 32 deletions.
37 changes: 37 additions & 0 deletions .github/actions/issue-labeled/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Issue ADO Create
description: Generate a work item for labeled issue
inputs:
token:
description: GitHub token with issue, comment, and label read/write permissions
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-labeled/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',
this.bugIteration,
);
}

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;
}
}
88 changes: 88 additions & 0 deletions .github/actions/issue-labeled/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 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 featureArea = getInput('feature-area-path');
const featureIteration = getInput('feature-iteration-path');

class Labeled extends Action {
id = 'Labeled';

async onLabeled(issue: OctoKitIssue) {
const content = await issue.getIssue();
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> labeled 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 + content.title;
let workItem: WorkItemTrackingInterfaces.WorkItem;
safeLog(`issue labeled with ${bugLabel}. Bug work item will be created. ${sprintPath}`);
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 labeled with sprint 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.onLabeled(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 Labeled().run(); // eslint-disable-line
25 changes: 7 additions & 18 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,15 @@ jobs:
PREID: ${{ github.event.inputs.preid }}
steps:
- name: Validate CD branch
if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/hotfix/') && github.ref != 'refs/heads/dev' && !startsWith(github.ref, 'refs/heads/release/') }}
if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/dev' && !startsWith(github.ref, 'refs/heads/release/') }}
run: |
echo It's not allowed to run CD on other branch except main and dev.
exit 1
- name: Validate inputs for main or hotfix
if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.event.inputs.preid != 'beta' && github.event.inputs.preid != 'rc' && github.event.inputs.preid != 'stable' }}
run: |
echo It's only allowed to release RC and stable on main branch.
echo It's allowed to run CD on dev or release branch.
exit 1
- name: Validate inputs for release
if: ${{ github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/release/') && (github.event.inputs.preid != 'stable' && github.event.inputs.preid != 'rc')}}
if: ${{ github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/release/') && github.event.inputs.preid == 'alpha' }}
run: |
echo It's only allowed to release stable on release branch
echo It's not allowed to run CD on release branch for alpha.
exit 1
- name: Valiadte inputs for dev
Expand Down Expand Up @@ -104,19 +98,14 @@ jobs:
npx lerna version prerelease --preid=alpha.$(git rev-parse --short HEAD) --exact --no-push --allow-branch dev --yes
- name: release beta packages to npmjs.org
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/hotfix/')) && github.event_name == 'workflow_dispatch' && github.event.inputs.preid == 'beta' }}
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.preid == 'beta' }}
run: |
npx lerna version prerelease --preid=beta.$(date "+%Y%m%d%H") --exact --no-push --allow-branch ${GITHUB_REF#refs/*/} --yes
- name: version rc npm packages to npmjs.org
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.preid == 'rc' && github.ref == 'refs/heads/main' && github.event.inputs.skip-version-rc == 'no'}}
run: |
npx lerna version prerelease --conventional-prerelease --preid=rc --no-changelog --yes
- name: version rc npm packages to npmjs.org on hotfix
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.preid == 'rc' && (startsWith(github.ref, 'refs/heads/hotfix/') || startsWith(github.ref, 'refs/heads/release/')) && github.event.inputs.skip-version-rc == 'no'}}
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.preid == 'rc' && github.event.inputs.skip-version-rc == 'no'}}
run: |
npx lerna version prerelease --conventional-prerelease --preid=rc-hotfix --no-changelog --allow-branch ${GITHUB_REF#refs/*/} --yes
npx lerna version prerelease --conventional-prerelease --preid=rc --no-changelog --allow-branch ${GITHUB_REF#refs/*/} --yes
- name: version stable npm packages to npmjs.org
if: ${{ github.event_name == 'workflow_dispatch' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/hotfix/') || startsWith(github.ref, 'refs/heads/release/')) && github.event.inputs.preid == 'stable' }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/cd_trigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ name: TeamsFx-CD Triggers
on:
workflow_run:
workflows: ["CD"]
branches: ["main", "dev", "hotfix/**"]
branches: ["dev", "release/**"]
types: ["completed"]

jobs:
TeamsFxCICDTestMainTriggers:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
if: ${{ github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'release') }}
steps:
- name: Trigger TeamsFx-CICD-Test
run: |
Expand Down
Loading

0 comments on commit 9bfb4e8

Please sign in to comment.