Skip to content

Commit

Permalink
feat: added projects support
Browse files Browse the repository at this point in the history
  • Loading branch information
SebastianKuesters committed Mar 25, 2024
1 parent 44a5d17 commit 89cf3ee
Show file tree
Hide file tree
Showing 11 changed files with 387 additions and 17 deletions.
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

## Inputs

| Name | Required | Description |
| ---------------------- | -------- | ------------------------------------------- |
| `github-token` | `true` | GitHub token |
| `release-version-tag` | `true` | Name of a tag. defaults to `github.ref` |
| `release-title` | `true` | Title of the release |
| `pre-release` | `false` | Indicator of whether or not is a prerelease |
| `previous-version-tag` | `false` | Name of the previous tag |
| `dry-run` | `false` | Indicator of whether or not is a dry run |
| Name | Required | Description |
| ---------------------------- | -------- | -------------------------------------------------------------- |
| `github-token` | `true` | GitHub token |
| `release-version-tag` | `true` | Name of a tag. defaults to `github.ref` |
| `release-title` | `true` | Title of the release |
| `pre-release` | `false` | Indicator of whether or not is a prerelease |
| `previous-version-tag` | `false` | Name of the previous tag |
| `dry-run` | `false` | Indicator of whether or not is a dry run |
| `label-issues-with` | `false` | Label to be added to issues (e.g. `released-dev`) |
| `project-number` | `false` | Number of the project to upsert issues |
| `project-status-column-name` | `false` | Name of the status column to upsert issues (e.g `In Progress`) |
12 changes: 12 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ inputs:
previous-version-tag:
description: The name of the previous version
default: ""
dry-run:
description: Indicator of whether or not is a dry run
default: "false"
label-issues-with:
description: Label to be added to issues (e.g. `released-dev`)
default: ""
project-number:
description: Number of the project to upsert issues
default: ""
project-status-column-name:
description: Name of the status column to upsert issues (e.g `In Progress`)
default: ""

runs:
using: "node20"
Expand Down
44 changes: 36 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Octokit } from "@octokit/rest";
import ReleaseNotesGenerator from "./ReleaseNotesGenerator";
import GitHubRepositoryUtils from "./utils/GitHubRepositoryUtils";
import GitHubProjectsUtils from "./utils/GitHubProjectsUtils";

const github = require("@actions/github");
const core = require("@actions/core");
Expand All @@ -18,6 +19,13 @@ async function run() {
const preRelease =
core.getInput("pre-release", { required: false }) === "true";
const dryRun = core.getInput("dry-run", { required: false }) === "true";
const labelIssuesWith = core.getInput("label-issues-with", {
required: false,
});
const projectNumber = core.getInput("project-number", { required: false });
const projectStatusColumnName = core.getInput("project-status-column-name", {
required: false,
});

// Get context information
const { owner, repo } = github.context.repo;
Expand All @@ -27,6 +35,7 @@ async function run() {
auth: token,
});
const gitHubRepositoryUtils = new GitHubRepositoryUtils(owner, repo, octokit);
const gitHubProjectsUtils = new GitHubProjectsUtils(owner, repo, octokit);
const releaseNotesGenerator = new ReleaseNotesGenerator();

// Get the referenced issues between the previous release and the current release
Expand All @@ -46,17 +55,36 @@ async function run() {
core.info(
`Dry run: Would have created release with the following notes:\n${releaseNotes}`
);
return;
} else {
core.info(`Creating release with the following notes:\n${releaseNotes}`);

await gitHubRepositoryUtils.createRelease(
releaseVersionTag,
releaseTitle,
releaseNotes || "No release notes provided",
preRelease
);
}

core.info(`Creating release with the following notes:\n${releaseNotes}`);
// Label issues
if (labelIssuesWith) {
const issueNumbers = issues.map((issue) => issue.number);
await gitHubRepositoryUtils.addLabelToIssues(issueNumbers, labelIssuesWith);
}

await gitHubRepositoryUtils.createRelease(
releaseVersionTag,
releaseTitle,
releaseNotes || "No release notes provided",
preRelease
);
// Update project
if (projectNumber && projectStatusColumnName) {
const parsedProjectNumber = parseInt(projectNumber);

for (const issue of issues) {
await gitHubProjectsUtils.updateProjectField(
parsedProjectNumber,
issue.number,
"Status",
projectStatusColumnName
);
}
}
}

run();
9 changes: 9 additions & 0 deletions src/types/ProjectAddItemResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type ProjectAddItemResponse = {
addProjectV2ItemById: {
item: {
id: string;
};
};
};

export default ProjectAddItemResponse;
29 changes: 29 additions & 0 deletions src/types/ProjectFieldsResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type ProjectFieldIteration = {
id: string;
startDate: string;
};

type ProjectFieldData = {
id: string;
name: string;
dataType: string;
options?: [
{
id: string;
name: string;
}
];
configuration?: {
iterations: [ProjectFieldIteration];
};
};

type ProjectFieldsResponse = {
node: {
fields: {
nodes: [ProjectFieldData];
};
};
};

export default ProjectFieldsResponse;
15 changes: 15 additions & 0 deletions src/types/ProjectNodeIdResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
type ProjectNodeIdResponse = {
organization?: {
projectV2: {
id: string;
};
};

user?: {
projectV2: {
id: string;
};
};
};

export default ProjectNodeIdResponse;
9 changes: 9 additions & 0 deletions src/types/ProjectUpdateFieldItemResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type ProjectUpdateFieldItemResponse = {
updateProjectV2ItemFieldValue: {
projectV2Item: {
id: string;
};
};
};

export default ProjectUpdateFieldItemResponse;
195 changes: 195 additions & 0 deletions src/utils/GitHubProjectsUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Octokit } from "@octokit/rest";
import ProjectNodeIdResponse from "../types/ProjectNodeIdResponse";
import ProjectFieldsResponse from "../types/ProjectFieldsResponse";
import ProjectUpdateFieldItemResponse from "../types/ProjectUpdateFieldItemResponse";
import ProjectAddItemResponse from "../types/ProjectAddItemResponse";

/**
* Implementation based on these projects:
* - https://github.com/titoportas/update-project-fields
* - https://github.com/actions/add-to-project
*/
export default class GitHubProjectsUtils {
public constructor(
private readonly owner: string,
private readonly repo: string,
private readonly octokit: Octokit
) {}

public async getProjectId(projectNumber: number) {
// Destructuring
const { owner, repo, octokit } = this;

// Determine the type of owner to query
const repository = await octokit.repos.get({
owner,
repo,
});
const ownerType = repository.data.owner.type.toLowerCase();

// First, use the GraphQL API to request the project's node ID.
const idResp = await octokit.graphql<ProjectNodeIdResponse>(
`query getProject($projectOwnerName: String!, $projectNumber: Int!) {
${ownerType}(login: $projectOwnerName) {
projectV2(number: $projectNumber) {
id
}
}
}`,
{
projectOwnerName: owner,
projectNumber,
}
);

return idResp[ownerType].projectV2.id;
}

public async updateProjectField(
projectNumber: number,
issueNumber: number,
fieldId: string,
value: string
) {
// Destructuring
const { octokit } = this;

// Get the project ID
const projectId = await this.getProjectId(projectNumber);
const issue = await this.octokit.issues.get({
owner: this.owner,
repo: this.repo,
issue_number: issueNumber,
});

// Add issue to project
const addResp = await octokit.graphql<ProjectAddItemResponse>(
`mutation addIssueToProject($input: AddProjectV2ItemByIdInput!) {
addProjectV2ItemById(input: $input) {
item {
id
}
}
}`,
{
input: {
projectId,
contentId: issue.data.node_id,
},
}
);

// Use the GraphQL API to request the project's columns.
const projectFields: ProjectFieldsResponse =
await octokit.graphql<ProjectFieldsResponse>(
`query getProjectFields($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 100) {
nodes {
... on ProjectV2Field {
id
dataType
name
}
... on ProjectV2IterationField {
id
name
dataType
configuration {
iterations {
startDate
id
}
}
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
}
}
}
}
}
}
}`,
{
projectId,
}
);

const field = projectFields.node.fields.nodes.find(
(node) => fieldId === node.name
);

if (!field) {
throw new Error(`Field with ID ${fieldId} not found`);
}

if (field.options) {
let option;
if (value.startsWith("[") && value.endsWith("]")) {
const index = parseInt(value.slice(1, -1));
if (!isNaN(index)) {
option = field.options[index];
}
} else {
option = field.options.find(
(o) => o.name.toLowerCase() === value.toLowerCase()
);
}
if (option) {
value = option.id;
}
}

const updateFieldKey = this.getUpdateFieldValueKey(field.dataType);

const updatedItem: ProjectUpdateFieldItemResponse =
await octokit.graphql<ProjectUpdateFieldItemResponse>(
`mutation updateProjectV2ItemFieldValue($input: UpdateProjectV2ItemFieldValueInput!) {
updateProjectV2ItemFieldValue(input: $input) {
projectV2Item {
id
}
}
}`,
{
input: {
projectId,
itemId: addResp.addProjectV2ItemById.item.id,
fieldId: field.id,
value: {
[updateFieldKey]: value,
},
},
}
);

return updatedItem;
}

private getUpdateFieldValueKey(
fieldDataType: string
): "text" | "number" | "date" | "singleSelectOptionId" | "iterationId" {
if (fieldDataType === "TEXT") {
return "text";
} else if (fieldDataType === "NUMBER") {
return "number";
} else if (fieldDataType === "DATE") {
return "date";
} else if (fieldDataType === "ITERATION") {
return "iterationId";
} else if (fieldDataType === "SINGLE_SELECT") {
return "singleSelectOptionId";
} else {
throw new Error(
`Unsupported dataType: ${fieldDataType}. Must be one of 'text', 'number', 'date', 'singleSelectOptionId', 'iterationId'`
);
}
}
}
13 changes: 13 additions & 0 deletions src/utils/GitHubRepositoryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,17 @@ export default class GitHubRepositoryUtils {
prerelease,
});
}

public async addLabelToIssues(issueNumbers: number[], label: string) {
await Promise.all(
issueNumbers.map(async (issueNumber) => {
await this.octokit.issues.addLabels({
owner: this.owner,
repo: this.repo,
issue_number: issueNumber,
labels: [label],
});
})
);
}
}
Loading

0 comments on commit 89cf3ee

Please sign in to comment.